1use std::io::{Cursor, Read};
16
17use serde::Serialize;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct PlateInspection {
22 pub plate: u32,
24 pub gcode_md5: String,
27 pub sidecar_md5: Option<String>,
30 pub sidecar_matches: bool,
34 pub bed_type: Option<String>,
36 pub filament_colors: Vec<String>,
38 pub has_timelapse_blocks: bool,
46}
47
48#[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#[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
76const MAX_ENTRY_BYTES: u64 = 256 * 1024 * 1024;
78
79pub 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 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
95pub 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 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 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
142fn 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) .count()
159 > 1
160}
161
162pub fn verify_expectations(
165 inspection: &PlateInspection,
166 requested_plate: u32,
167 expect_md5: Option<&str>,
168 expect_plate: Option<u32>,
169) -> Result<(), ExpectError> {
170 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
200fn 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
222fn 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 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 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 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 ("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())); 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); }
299
300 #[test]
301 fn detects_per_layer_timelapse_block_injection() {
302 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 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 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); 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")); }
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 assert!(
386 verify_expectations(
387 &inspection,
388 1,
389 Some(" F4DC55FD36F79D26ACA4003E36B48D4F "),
390 None
391 )
392 .is_ok()
393 );
394 assert!(matches!(
396 verify_expectations(&inspection, 1, Some("deadbeef"), None),
397 Err(ExpectError::Md5Mismatch { .. })
398 ));
399 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 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 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}