use std::io::{Cursor, Read};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlateInspection {
pub plate: u32,
pub gcode_md5: String,
pub sidecar_md5: Option<String>,
pub sidecar_matches: bool,
pub bed_type: Option<String>,
pub filament_colors: Vec<String>,
pub has_timelapse_blocks: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
#[error("not a valid .3mf (zip): {0}")]
InvalidZip(String),
#[error("plate {0} is not in the .3mf (no Metadata/plate_{0}.gcode)")]
PlateMissing(u32),
#[error("sidecar Metadata/plate_{0}.gcode.md5 is not valid md5 hex")]
InvalidSidecarMd5(u32),
#[error(".3mf entry exceeds the {limit_mb} MB inspection cap")]
TooLarge { limit_mb: u64 },
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ExpectError {
#[error("expected md5 {expected} but plate {plate}'s gcode is {actual}")]
Md5Mismatch {
plate: u32,
expected: String,
actual: String,
},
#[error("expected plate {expected} but --plate is {requested}")]
PlateMismatch { expected: u32, requested: u32 },
}
const MAX_ENTRY_BYTES: u64 = 256 * 1024 * 1024;
pub fn gcode_md5_hex(bytes: &[u8]) -> String {
use md5::{Digest, Md5};
use std::fmt::Write;
let mut hasher = Md5::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.fold(String::with_capacity(32), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
pub fn inspect_plate(zip_bytes: &[u8], plate: u32) -> Result<PlateInspection, ProjectError> {
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes))
.map_err(|e| ProjectError::InvalidZip(e.to_string()))?;
let gcode = read_entry(&mut archive, &format!("Metadata/plate_{plate}.gcode"))?
.ok_or(ProjectError::PlateMissing(plate))?;
let gcode_md5 = gcode_md5_hex(&gcode);
let sidecar_md5 = match read_entry(&mut archive, &format!("Metadata/plate_{plate}.gcode.md5"))?
{
Some(raw) => {
let token = String::from_utf8_lossy(&raw)
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
if token.len() != 32 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ProjectError::InvalidSidecarMd5(plate));
}
Some(token)
}
None => None,
};
let sidecar_matches = sidecar_md5.as_deref().is_none_or(|s| s == gcode_md5);
let (bed_type, filament_colors) =
match read_entry(&mut archive, &format!("Metadata/plate_{plate}.json"))? {
Some(raw) => parse_plate_json(&raw),
None => (None, Vec::new()),
};
let has_timelapse_blocks = injects_timelapse_blocks(&gcode);
Ok(PlateInspection {
plate,
gcode_md5,
sidecar_md5,
sidecar_matches,
bed_type,
filament_colors,
has_timelapse_blocks,
})
}
fn injects_timelapse_blocks(gcode: &[u8]) -> bool {
const MARKER: &[u8] = b"SKIPTYPE: timelapse";
gcode
.windows(MARKER.len())
.filter(|w| *w == MARKER)
.take(2) .count()
> 1
}
pub fn verify_expectations(
inspection: &PlateInspection,
requested_plate: u32,
expect_md5: Option<&str>,
expect_plate: Option<u32>,
) -> Result<(), ExpectError> {
if inspection.plate != requested_plate {
return Err(ExpectError::PlateMismatch {
expected: inspection.plate,
requested: requested_plate,
});
}
if let Some(want) = expect_plate
&& want != requested_plate
{
return Err(ExpectError::PlateMismatch {
expected: want,
requested: requested_plate,
});
}
if let Some(want) = expect_md5 {
let want = want.trim().to_ascii_lowercase();
if want != inspection.gcode_md5 {
return Err(ExpectError::Md5Mismatch {
plate: inspection.plate,
expected: want,
actual: inspection.gcode_md5.clone(),
});
}
}
Ok(())
}
fn read_entry(
archive: &mut zip::ZipArchive<Cursor<&[u8]>>,
name: &str,
) -> Result<Option<Vec<u8>>, ProjectError> {
let file = match archive.by_name(name) {
Ok(f) => f,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(e) => return Err(ProjectError::InvalidZip(e.to_string())),
};
if file.size() > MAX_ENTRY_BYTES {
return Err(ProjectError::TooLarge {
limit_mb: MAX_ENTRY_BYTES / (1024 * 1024),
});
}
let mut buf = Vec::new();
file.take(MAX_ENTRY_BYTES)
.read_to_end(&mut buf)
.map_err(|e| ProjectError::InvalidZip(format!("reading {name}: {e}")))?;
Ok(Some(buf))
}
fn parse_plate_json(raw: &[u8]) -> (Option<String>, Vec<String>) {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(raw) else {
return (None, Vec::new());
};
let bed_type = v
.get("bed_type")
.and_then(|b| b.as_str())
.map(str::to_owned);
let colors = v
.get("filament_colors")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| c.as_str())
.filter(|s| s.len() <= 32)
.take(64)
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
(bed_type, colors)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
fn make_3mf(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, bytes) in entries {
zip.start_file(*name, opts).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
}
buf
}
#[test]
fn md5_is_lowercase_hex_of_the_bytes() {
assert_eq!(gcode_md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(gcode_md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
}
#[test]
fn inspects_a_plate_with_sidecar_and_json() {
let gcode = b"G28\nG1 X1 Y1\n";
let md5 = gcode_md5_hex(gcode);
let zip = make_3mf(&[
("Metadata/plate_1.gcode", gcode),
("Metadata/plate_1.gcode.md5", md5.to_uppercase().as_bytes()),
(
"Metadata/plate_1.json",
br##"{"bed_type":"textured_plate","filament_colors":["#F2754E","#000000"]}"##,
),
]);
let got = inspect_plate(&zip, 1).unwrap();
assert_eq!(got.gcode_md5, md5);
assert_eq!(got.sidecar_md5.as_deref(), Some(md5.as_str())); assert!(got.sidecar_matches);
assert_eq!(got.bed_type.as_deref(), Some("textured_plate"));
assert_eq!(got.filament_colors, vec!["#F2754E", "#000000"]);
assert!(!got.has_timelapse_blocks); }
#[test]
fn detects_per_layer_timelapse_block_injection() {
let injected = b"; time_lapse_gcode = ;SKIPTYPE: timelapse template\n\
G1 Z1\n; SKIPTYPE: timelapse\nM1004 S5 P1\n\
G1 Z2\n; SKIPTYPE: timelapse\nM1004 S5 P1\n";
let zip = make_3mf(&[("Metadata/plate_1.gcode", injected)]);
assert!(inspect_plate(&zip, 1).unwrap().has_timelapse_blocks);
let dump_only = b"; time_lapse_gcode = ;SKIPTYPE: timelapse template\nG28\nG1 X1\n";
let zip = make_3mf(&[("Metadata/plate_1.gcode", dump_only)]);
assert!(!inspect_plate(&zip, 1).unwrap().has_timelapse_blocks);
let none = b"G28\nG1 X1 Y1\nG1 Z0.2\n";
let zip = make_3mf(&[("Metadata/plate_1.gcode", none)]);
assert!(!inspect_plate(&zip, 1).unwrap().has_timelapse_blocks);
}
#[test]
fn missing_plate_is_an_error() {
let zip = make_3mf(&[("Metadata/plate_1.gcode", b"G28")]);
assert!(matches!(
inspect_plate(&zip, 2),
Err(ProjectError::PlateMissing(2))
));
}
#[test]
fn absent_sidecar_and_json_are_not_fatal() {
let zip = make_3mf(&[("Metadata/plate_1.gcode", b"G28")]);
let got = inspect_plate(&zip, 1).unwrap();
assert_eq!(got.sidecar_md5, None);
assert!(got.sidecar_matches); assert_eq!(got.bed_type, None);
assert!(got.filament_colors.is_empty());
}
#[test]
fn mismatched_sidecar_is_flagged_not_fatal_and_computed_value_wins() {
let zip = make_3mf(&[
("Metadata/plate_1.gcode", b"G28"),
(
"Metadata/plate_1.gcode.md5",
b"00000000000000000000000000000000",
),
]);
let got = inspect_plate(&zip, 1).unwrap();
assert!(!got.sidecar_matches);
assert_eq!(got.gcode_md5, gcode_md5_hex(b"G28")); }
#[test]
fn malformed_sidecar_is_rejected() {
let zip = make_3mf(&[
("Metadata/plate_1.gcode", b"G28"),
("Metadata/plate_1.gcode.md5", b"not-a-real-md5"),
]);
assert!(matches!(
inspect_plate(&zip, 1),
Err(ProjectError::InvalidSidecarMd5(1))
));
}
#[test]
fn not_a_zip_is_an_error() {
assert!(matches!(
inspect_plate(b"this is not a zip", 1),
Err(ProjectError::InvalidZip(_))
));
}
#[test]
fn verify_expectations_matches_case_insensitively() {
let inspection = PlateInspection {
plate: 1,
gcode_md5: "f4dc55fd36f79d26aca4003e36b48d4f".to_string(),
sidecar_md5: None,
sidecar_matches: true,
bed_type: None,
filament_colors: vec![],
has_timelapse_blocks: false,
};
assert!(
verify_expectations(
&inspection,
1,
Some(" F4DC55FD36F79D26ACA4003E36B48D4F "),
None
)
.is_ok()
);
assert!(matches!(
verify_expectations(&inspection, 1, Some("deadbeef"), None),
Err(ExpectError::Md5Mismatch { .. })
));
assert!(verify_expectations(&inspection, 1, None, Some(1)).is_ok());
assert_eq!(
verify_expectations(&inspection, 1, None, Some(2)),
Err(ExpectError::PlateMismatch {
expected: 2,
requested: 1
})
);
assert!(verify_expectations(&inspection, 1, None, None).is_ok());
}
#[test]
fn verify_expectations_rejects_an_inspection_of_the_wrong_plate() {
let inspection = PlateInspection {
plate: 1,
gcode_md5: "f4dc55fd36f79d26aca4003e36b48d4f".to_string(),
sidecar_md5: None,
sidecar_matches: true,
bed_type: None,
filament_colors: vec![],
has_timelapse_blocks: false,
};
assert_eq!(
verify_expectations(&inspection, 2, None, None),
Err(ExpectError::PlateMismatch {
expected: 1,
requested: 2
})
);
}
}