Skip to main content

bambu_rs/core/
project.rs

1//! Pure inspection of a sliced `.gcode.3mf` (a ZIP), and verification of
2//! caller-asserted expectations (`--expect-md5` / `--expect-plate`).
3//!
4//! **No I/O.** The caller reads the file's bytes (over FTPS, from the printer —
5//! the source of truth for what will actually print) and hands them in; this
6//! module opens the ZIP from memory and returns data or a typed error. Keeping
7//! it pure makes the safety-critical logic unit-testable without a network.
8//!
9//! Layout is from a **real A1 mini** `.gcode.3mf` (`tools/` capture): each plate
10//! has `Metadata/plate_N.gcode`, `Metadata/plate_N.gcode.md5` (the md5 hex of the
11//! gcode, stored UPPERCASE), and `Metadata/plate_N.json` (`bed_type`,
12//! `filament_colors`, …). We **compute** the md5 from the gcode bytes (the
13//! authoritative "what will print"); the sidecar is only a cross-check.
14
15use std::io::{Cursor, Read};
16
17use serde::Serialize;
18
19/// What we learned about one plate inside a `.gcode.3mf`.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct PlateInspection {
22    /// The plate number inspected.
23    pub plate: u32,
24    /// md5 of `Metadata/plate_N.gcode`, **lowercase hex** — computed from the
25    /// gcode bytes (the value `--expect-md5` is checked against).
26    pub gcode_md5: String,
27    /// `Metadata/plate_N.gcode.md5` normalised to lowercase hex, or `None` if the
28    /// sidecar is absent.
29    pub sidecar_md5: Option<String>,
30    /// Whether the sidecar matches the computed md5 (vacuously `true` with no
31    /// sidecar). A `false` here means the file's own checksum disagrees with its
32    /// bytes — surfaced as a warning, never trusted over the computed value.
33    pub sidecar_matches: bool,
34    /// `bed_type` from `Metadata/plate_N.json`, if present (e.g. `textured_plate`).
35    pub bed_type: Option<String>,
36    /// `filament_colors` from `Metadata/plate_N.json` (hex `#RRGGBB`), in order.
37    pub filament_colors: Vec<String>,
38    /// Whether the plate gcode INJECTS the slicer's per-layer timelapse block (the
39    /// head-park + external-shutter moves) at layer changes — the precondition for a
40    /// "clean", object-only timelapse. NOTE: even when present the block only RUNS if
41    /// timelapse is armed at print start (it's wrapped in an `M622 J1` runtime
42    /// conditional), so this is *capability*, not a guarantee the head will park. See
43    /// [`injects_timelapse_blocks`] for how it's detected (a gcode scan — the
44    /// `timelapse_type` metadata field is uniformly 0 and does NOT track this).
45    pub has_timelapse_blocks: bool,
46}
47
48/// A problem reading/parsing the `.3mf`.
49#[derive(Debug, thiserror::Error)]
50pub enum ProjectError {
51    #[error("not a valid .3mf (zip): {0}")]
52    InvalidZip(String),
53    #[error("plate {0} is not in the .3mf (no Metadata/plate_{0}.gcode)")]
54    PlateMissing(u32),
55    #[error("sidecar Metadata/plate_{0}.gcode.md5 is not valid md5 hex")]
56    InvalidSidecarMd5(u32),
57    #[error(".3mf entry exceeds the {limit_mb} MB inspection cap")]
58    TooLarge { limit_mb: u64 },
59}
60
61/// A caller-asserted expectation that the actual file did not meet.
62/// Separate from [`ProjectError`] so the CLI can give distinct, agent-parseable
63/// messages while mapping both to the validation exit code.
64#[derive(Debug, thiserror::Error, PartialEq, Eq)]
65pub enum ExpectError {
66    #[error("expected md5 {expected} but plate {plate}'s gcode is {actual}")]
67    Md5Mismatch {
68        plate: u32,
69        expected: String,
70        actual: String,
71    },
72    #[error("expected plate {expected} but --plate is {requested}")]
73    PlateMismatch { expected: u32, requested: u32 },
74}
75
76/// Max bytes read from any single ZIP entry during inspection (zip-bomb guard).
77const MAX_ENTRY_BYTES: u64 = 256 * 1024 * 1024;
78
79/// Lowercase-hex md5 of `bytes`.
80pub fn gcode_md5_hex(bytes: &[u8]) -> String {
81    use md5::{Digest, Md5};
82    use std::fmt::Write;
83    let mut hasher = Md5::new();
84    hasher.update(bytes);
85    // RustCrypto's digest output doesn't impl LowerHex, so hex-encode by hand.
86    hasher
87        .finalize()
88        .iter()
89        .fold(String::with_capacity(32), |mut s, b| {
90            let _ = write!(s, "{b:02x}");
91            s
92        })
93}
94
95/// Inspect one plate of a `.gcode.3mf` given its raw ZIP bytes.
96pub fn inspect_plate(zip_bytes: &[u8], plate: u32) -> Result<PlateInspection, ProjectError> {
97    let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes))
98        .map_err(|e| ProjectError::InvalidZip(e.to_string()))?;
99
100    let gcode = read_entry(&mut archive, &format!("Metadata/plate_{plate}.gcode"))?
101        .ok_or(ProjectError::PlateMissing(plate))?;
102    let gcode_md5 = gcode_md5_hex(&gcode);
103
104    // Optional sidecar checksum (cross-check only).
105    let sidecar_md5 = match read_entry(&mut archive, &format!("Metadata/plate_{plate}.gcode.md5"))?
106    {
107        Some(raw) => {
108            let token = String::from_utf8_lossy(&raw)
109                .split_whitespace()
110                .next()
111                .unwrap_or("")
112                .to_ascii_lowercase();
113            if token.len() != 32 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
114                return Err(ProjectError::InvalidSidecarMd5(plate));
115            }
116            Some(token)
117        }
118        None => None,
119    };
120    let sidecar_matches = sidecar_md5.as_deref().is_none_or(|s| s == gcode_md5);
121
122    // Best-effort plate metadata (never fatal).
123    let (bed_type, filament_colors) =
124        match read_entry(&mut archive, &format!("Metadata/plate_{plate}.json"))? {
125            Some(raw) => parse_plate_json(&raw),
126            None => (None, Vec::new()),
127        };
128
129    let has_timelapse_blocks = injects_timelapse_blocks(&gcode);
130
131    Ok(PlateInspection {
132        plate,
133        gcode_md5,
134        sidecar_md5,
135        sidecar_matches,
136        bed_type,
137        filament_colors,
138        has_timelapse_blocks,
139    })
140}
141
142/// Does the plate gcode INJECT the per-layer timelapse block (head-park + external
143/// shutter), vs merely list it in the settings dump or omit it entirely?
144///
145/// Detected by the slicer's `SKIPTYPE: timelapse` marker. A file that injects the block at
146/// each layer change has MANY (one per layer); a file that only echoes the `time_lapse_gcode`
147/// template in its machine-settings dump has at most ONE; an older profile without the block
148/// has NONE. So `> 1` separates "injected per layer" from "dump-only / absent" — conservative
149/// on purpose: a single (dump-only) mention reads as "no", so we never falsely promise the
150/// head will park. (`timelapse_type` in the 3mf metadata is uniformly 0 and useless here —
151/// device-verified across real slices.)
152fn injects_timelapse_blocks(gcode: &[u8]) -> bool {
153    const MARKER: &[u8] = b"SKIPTYPE: timelapse";
154    gcode
155        .windows(MARKER.len())
156        .filter(|w| *w == MARKER)
157        .take(2) // early-out: two occurrences already proves per-layer injection
158        .count()
159        > 1
160}
161
162/// Verify caller-asserted expectations against an inspected plate.
163/// `expect_plate`, when given, must equal the `--plate` actually requested.
164pub fn verify_expectations(
165    inspection: &PlateInspection,
166    requested_plate: u32,
167    expect_md5: Option<&str>,
168    expect_plate: Option<u32>,
169) -> Result<(), ExpectError> {
170    // Defensive: the inspection must be of the plate we're verifying. The CLI
171    // always inspects `requested_plate`, but enforcing the invariant here keeps a
172    // future caller from accidentally verifying md5 against the wrong plate.
173    if inspection.plate != requested_plate {
174        return Err(ExpectError::PlateMismatch {
175            expected: inspection.plate,
176            requested: requested_plate,
177        });
178    }
179    if let Some(want) = expect_plate
180        && want != requested_plate
181    {
182        return Err(ExpectError::PlateMismatch {
183            expected: want,
184            requested: requested_plate,
185        });
186    }
187    if let Some(want) = expect_md5 {
188        let want = want.trim().to_ascii_lowercase();
189        if want != inspection.gcode_md5 {
190            return Err(ExpectError::Md5Mismatch {
191                plate: inspection.plate,
192                expected: want,
193                actual: inspection.gcode_md5.clone(),
194            });
195        }
196    }
197    Ok(())
198}
199
200/// Read a ZIP entry fully (capped), `Ok(None)` if the entry isn't present.
201fn read_entry(
202    archive: &mut zip::ZipArchive<Cursor<&[u8]>>,
203    name: &str,
204) -> Result<Option<Vec<u8>>, ProjectError> {
205    let file = match archive.by_name(name) {
206        Ok(f) => f,
207        Err(zip::result::ZipError::FileNotFound) => return Ok(None),
208        Err(e) => return Err(ProjectError::InvalidZip(e.to_string())),
209    };
210    if file.size() > MAX_ENTRY_BYTES {
211        return Err(ProjectError::TooLarge {
212            limit_mb: MAX_ENTRY_BYTES / (1024 * 1024),
213        });
214    }
215    let mut buf = Vec::new();
216    file.take(MAX_ENTRY_BYTES)
217        .read_to_end(&mut buf)
218        .map_err(|e| ProjectError::InvalidZip(format!("reading {name}: {e}")))?;
219    Ok(Some(buf))
220}
221
222/// Extract `bed_type` + `filament_colors` from a `plate_N.json` (best-effort).
223fn parse_plate_json(raw: &[u8]) -> (Option<String>, Vec<String>) {
224    let Ok(v) = serde_json::from_slice::<serde_json::Value>(raw) else {
225        return (None, Vec::new());
226    };
227    let bed_type = v
228        .get("bed_type")
229        .and_then(|b| b.as_str())
230        .map(str::to_owned);
231    // Bound the colours defensively (a malicious .3mf could pack a huge array);
232    // a real plate has a handful. Cap count and per-entry length.
233    let colors = v
234        .get("filament_colors")
235        .and_then(|c| c.as_array())
236        .map(|arr| {
237            arr.iter()
238                .filter_map(|c| c.as_str())
239                .filter(|s| s.len() <= 32)
240                .take(64)
241                .map(str::to_owned)
242                .collect()
243        })
244        .unwrap_or_default();
245    (bed_type, colors)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use std::io::Write;
252    use zip::write::SimpleFileOptions;
253
254    /// Build a `.3mf` (ZIP) in memory from (name, bytes) entries — deflate, so it
255    /// exercises the same path real Bambu files use. No committed binary fixture.
256    fn make_3mf(entries: &[(&str, &[u8])]) -> Vec<u8> {
257        let mut buf = Vec::new();
258        {
259            let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
260            let opts =
261                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
262            for (name, bytes) in entries {
263                zip.start_file(*name, opts).unwrap();
264                zip.write_all(bytes).unwrap();
265            }
266            zip.finish().unwrap();
267        }
268        buf
269    }
270
271    #[test]
272    fn md5_is_lowercase_hex_of_the_bytes() {
273        // Known vectors.
274        assert_eq!(gcode_md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
275        assert_eq!(gcode_md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
276    }
277
278    #[test]
279    fn inspects_a_plate_with_sidecar_and_json() {
280        let gcode = b"G28\nG1 X1 Y1\n";
281        let md5 = gcode_md5_hex(gcode);
282        let zip = make_3mf(&[
283            ("Metadata/plate_1.gcode", gcode),
284            // sidecar stored UPPERCASE, like the real device.
285            ("Metadata/plate_1.gcode.md5", md5.to_uppercase().as_bytes()),
286            (
287                "Metadata/plate_1.json",
288                br##"{"bed_type":"textured_plate","filament_colors":["#F2754E","#000000"]}"##,
289            ),
290        ]);
291        let got = inspect_plate(&zip, 1).unwrap();
292        assert_eq!(got.gcode_md5, md5);
293        assert_eq!(got.sidecar_md5.as_deref(), Some(md5.as_str())); // normalised lowercase
294        assert!(got.sidecar_matches);
295        assert_eq!(got.bed_type.as_deref(), Some("textured_plate"));
296        assert_eq!(got.filament_colors, vec!["#F2754E", "#000000"]);
297        assert!(!got.has_timelapse_blocks); // this gcode has no timelapse markers
298    }
299
300    #[test]
301    fn detects_per_layer_timelapse_block_injection() {
302        // Injected at layer changes (the settings dump + several layers) → many markers.
303        let injected = b"; time_lapse_gcode = ;SKIPTYPE: timelapse template\n\
304                         G1 Z1\n; SKIPTYPE: timelapse\nM1004 S5 P1\n\
305                         G1 Z2\n; SKIPTYPE: timelapse\nM1004 S5 P1\n";
306        let zip = make_3mf(&[("Metadata/plate_1.gcode", injected)]);
307        assert!(inspect_plate(&zip, 1).unwrap().has_timelapse_blocks);
308
309        // Only the machine-settings dump mentions it once (not injected per layer) → no.
310        let dump_only = b"; time_lapse_gcode = ;SKIPTYPE: timelapse template\nG28\nG1 X1\n";
311        let zip = make_3mf(&[("Metadata/plate_1.gcode", dump_only)]);
312        assert!(!inspect_plate(&zip, 1).unwrap().has_timelapse_blocks);
313
314        // Older profile with no timelapse gcode at all → no.
315        let none = b"G28\nG1 X1 Y1\nG1 Z0.2\n";
316        let zip = make_3mf(&[("Metadata/plate_1.gcode", none)]);
317        assert!(!inspect_plate(&zip, 1).unwrap().has_timelapse_blocks);
318    }
319
320    #[test]
321    fn missing_plate_is_an_error() {
322        let zip = make_3mf(&[("Metadata/plate_1.gcode", b"G28")]);
323        assert!(matches!(
324            inspect_plate(&zip, 2),
325            Err(ProjectError::PlateMissing(2))
326        ));
327    }
328
329    #[test]
330    fn absent_sidecar_and_json_are_not_fatal() {
331        let zip = make_3mf(&[("Metadata/plate_1.gcode", b"G28")]);
332        let got = inspect_plate(&zip, 1).unwrap();
333        assert_eq!(got.sidecar_md5, None);
334        assert!(got.sidecar_matches); // vacuously
335        assert_eq!(got.bed_type, None);
336        assert!(got.filament_colors.is_empty());
337    }
338
339    #[test]
340    fn mismatched_sidecar_is_flagged_not_fatal_and_computed_value_wins() {
341        let zip = make_3mf(&[
342            ("Metadata/plate_1.gcode", b"G28"),
343            (
344                "Metadata/plate_1.gcode.md5",
345                b"00000000000000000000000000000000",
346            ),
347        ]);
348        let got = inspect_plate(&zip, 1).unwrap();
349        assert!(!got.sidecar_matches);
350        assert_eq!(got.gcode_md5, gcode_md5_hex(b"G28")); // computed, not the sidecar
351    }
352
353    #[test]
354    fn malformed_sidecar_is_rejected() {
355        let zip = make_3mf(&[
356            ("Metadata/plate_1.gcode", b"G28"),
357            ("Metadata/plate_1.gcode.md5", b"not-a-real-md5"),
358        ]);
359        assert!(matches!(
360            inspect_plate(&zip, 1),
361            Err(ProjectError::InvalidSidecarMd5(1))
362        ));
363    }
364
365    #[test]
366    fn not_a_zip_is_an_error() {
367        assert!(matches!(
368            inspect_plate(b"this is not a zip", 1),
369            Err(ProjectError::InvalidZip(_))
370        ));
371    }
372
373    #[test]
374    fn verify_expectations_matches_case_insensitively() {
375        let inspection = PlateInspection {
376            plate: 1,
377            gcode_md5: "f4dc55fd36f79d26aca4003e36b48d4f".to_string(),
378            sidecar_md5: None,
379            sidecar_matches: true,
380            bed_type: None,
381            filament_colors: vec![],
382            has_timelapse_blocks: false,
383        };
384        // Uppercase + whitespace asserted value still matches.
385        assert!(
386            verify_expectations(
387                &inspection,
388                1,
389                Some("  F4DC55FD36F79D26ACA4003E36B48D4F "),
390                None
391            )
392            .is_ok()
393        );
394        // Wrong md5.
395        assert!(matches!(
396            verify_expectations(&inspection, 1, Some("deadbeef"), None),
397            Err(ExpectError::Md5Mismatch { .. })
398        ));
399        // expect-plate must equal the requested plate.
400        assert!(verify_expectations(&inspection, 1, None, Some(1)).is_ok());
401        assert_eq!(
402            verify_expectations(&inspection, 1, None, Some(2)),
403            Err(ExpectError::PlateMismatch {
404                expected: 2,
405                requested: 1
406            })
407        );
408        // No expectations -> ok.
409        assert!(verify_expectations(&inspection, 1, None, None).is_ok());
410    }
411
412    #[test]
413    fn verify_expectations_rejects_an_inspection_of_the_wrong_plate() {
414        // Defensive invariant: inspecting plate 1 but verifying for plate 2 must
415        // fail even with no caller expectations (guards against a future bug).
416        let inspection = PlateInspection {
417            plate: 1,
418            gcode_md5: "f4dc55fd36f79d26aca4003e36b48d4f".to_string(),
419            sidecar_md5: None,
420            sidecar_matches: true,
421            bed_type: None,
422            filament_colors: vec![],
423            has_timelapse_blocks: false,
424        };
425        assert_eq!(
426            verify_expectations(&inspection, 2, None, None),
427            Err(ExpectError::PlateMismatch {
428                expected: 1,
429                requested: 2
430            })
431        );
432    }
433}