diff --git a/src/printer/bambu_commands/print.rs b/src/printer/bambu_commands/print.rs index 732990f..80fbeb6 100644 --- a/src/printer/bambu_commands/print.rs +++ b/src/printer/bambu_commands/print.rs @@ -160,7 +160,11 @@ impl PrintReport { self.envelope = newer.envelope; self.msg = newer.msg; - self.ams = newer.ams.or(self.ams.take()); + // A partial `ams` object (no unit list) must not wipe the units already known. + self.ams = match newer.ams { + Some(partial) if partial.ams.is_empty() => self.ams.take(), + other => other.or(self.ams.take()), + }; self.ams_exist_bits = newer.ams_exist_bits.or(self.ams_exist_bits.take()); self.insert_flag = newer.insert_flag.or(self.insert_flag.take()); self.power_on_flag = newer.power_on_flag.or(self.power_on_flag.take()); @@ -244,6 +248,10 @@ pub struct LightReport { /// actually sends. #[derive(serde::Deserialize, Debug, Clone)] pub struct AmsState { + /// Absent from the partial `ams` objects a P1P pushes mid-print (found on + /// the first real dispatch, 2026-10-29: every update was dropped as + /// "missing field `ams`" and the job could never complete). + #[serde(default)] pub ams: Vec, } diff --git a/src/uplink/download.rs b/src/uplink/download.rs index 2ac185c..beaaeff 100644 --- a/src/uplink/download.rs +++ b/src/uplink/download.rs @@ -26,6 +26,26 @@ pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180); /// up the whole budget above. pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// Dev-only escape hatch for a local object store (MinIO) that speaks plain +/// HTTP: needs `CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true` AND a loopback +/// or private-network (RFC 1918) address literal, so a presigned URL can never +/// cross a public network in the clear. +fn insecure_local_allowed(url: &reqwest::Url) -> bool { + use std::net::IpAddr; + if url.scheme() != "http" + || !std::env::var("CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS").is_ok_and(|v| v == "true") + { + return false; + } + let Some(ip) = url.host_str().and_then(|h| h.trim_matches(['[', ']']).parse::().ok()) else { + return false; + }; + match ip { + IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(), + IpAddr::V6(v6) => v6.is_loopback(), + } +} + /// Downloads `url` fully into memory, or says why not. /// /// The error is a `String` naming the failure; the caller prefixes the stage. @@ -39,7 +59,7 @@ pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result, let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?; // A presigned URL carries its authority in the query string; sending it // over plain HTTP would expose it to anyone on the path. - if parsed.scheme() != "https" { + if parsed.scheme() != "https" && !insecure_local_allowed(&parsed) { return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme())); } diff --git a/tests/push_status_parsing.rs b/tests/push_status_parsing.rs index 42d5f34..a2c0d96 100644 --- a/tests/push_status_parsing.rs +++ b/tests/push_status_parsing.rs @@ -144,3 +144,13 @@ fn merge_keeps_stage_and_spool_when_a_partial_update_omits_them() { assert_eq!(base.stg, Some(vec![54, 14, 13])); assert!(base.vt_tray.is_some()); } + +/// A P1P mid-print pushes `ams` objects without the inner unit list. That +/// used to fail the whole report ("missing field `ams`"), silencing telemetry +/// for the rest of the print - found on the first real dispatch. +#[test] +fn a_partial_ams_object_parses_and_keeps_known_units() { + let partial = serde_json::json!({"command":"push_status","sequence_id":"1","msg":1,"gcode_state":"RUNNING","ams":{"tray_now":"2"}}); + let report: PrintReport = serde_json::from_value(partial).expect("a partial ams object should parse"); + assert!(report.ams.as_ref().is_some_and(|a| a.ams.is_empty())); +}