Accept partial ams objects; allow plain-HTTP downloads from a local store on request
Windows discovery tool / windows (push) Successful in 57s

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.
This commit is contained in:
2026-10-29 22:13:47 +00:00
parent 0c449c47bc
commit 47290175aa
3 changed files with 40 additions and 2 deletions
+9 -1
View File
@@ -160,7 +160,11 @@ impl PrintReport {
self.envelope = newer.envelope; self.envelope = newer.envelope;
self.msg = newer.msg; 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.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.insert_flag = newer.insert_flag.or(self.insert_flag.take());
self.power_on_flag = newer.power_on_flag.or(self.power_on_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. /// actually sends.
#[derive(serde::Deserialize, Debug, Clone)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct AmsState { 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<AmsUnit>, pub ams: Vec<AmsUnit>,
} }
+21 -1
View File
@@ -26,6 +26,26 @@ pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
/// up the whole budget above. /// up the whole budget above.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); 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::<IpAddr>().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. /// Downloads `url` fully into memory, or says why not.
/// ///
/// The error is a `String` naming the failure; the caller prefixes the stage. /// 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<Vec<u8>,
let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?; 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 // A presigned URL carries its authority in the query string; sending it
// over plain HTTP would expose it to anyone on the path. // 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())); return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
} }
+10
View File
@@ -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_eq!(base.stg, Some(vec![54, 14, 13]));
assert!(base.vt_tray.is_some()); 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()));
}