From 47290175aa945e213b90b86b11bd6e473809ba11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 29 Oct 2026 22:13:47 +0000 Subject: [PATCH] Accept partial ams objects; allow plain-HTTP downloads from a local store on request A P1P mid-print pushes ams objects with no unit list; every such update was dropped as "missing field ams", silencing telemetry for the rest of the print (found on the first real dispatch). The unit list now defaults to empty and a partial object does not wipe known units. CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true lets the downloader fetch a presigned URL over plain HTTP, but only from a loopback or RFC 1918 address. --- src/printer/bambu_commands/print.rs | 10 +++++++++- src/uplink/download.rs | 22 +++++++++++++++++++++- tests/push_status_parsing.rs | 10 ++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) 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())); +}