diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs index 81881f6a9..3ff499124 100644 --- a/crates/stencilcraft-format/src/lib.rs +++ b/crates/stencilcraft-format/src/lib.rs @@ -18,10 +18,14 @@ //! [`DocumentFields`] owns the JSON field names, required and defaulted fields, and the //! flattening rule. Its values are JSON because the consuming app owns the document model, //! domain validation, and conversion between that model and the wire fields. The crate does not -//! open files or set a file-size policy; callers handle bounded file I/O. +//! open files or set a file-size policy; callers handle bounded file I/O. [`File::read_from`] +//! lets callers parse from a reader without first buffering source bytes in a string. -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; +use std::fmt; +use std::io::Read; /// The extension, without the leading dot. pub const EXTENSION: &str = "stencilcraft"; @@ -90,8 +94,21 @@ impl File { /// Parses JSON, validates the format header and version, then reads the wire fields. pub fn read(text: &str) -> Result { - let value: Value = serde_json::from_str(text).map_err(Error::Json)?; - let header = value.get(HEADER_KEY).and_then(Value::as_object).ok_or(Error::NotStencilCraft)?; + let wire: RawWire = serde_json::from_str(text).map_err(Error::Json)?; + Self::from_wire(wire) + } + + /// Parses JSON from a reader, without first buffering the source bytes in a string. + /// + /// Parsed document fields are still materialized as JSON values. Callers that impose a + /// total file-size policy should enforce it on the reader before calling this method. + pub fn read_from(reader: impl Read) -> Result { + let wire: RawWire = serde_json::from_reader(reader).map_err(Error::Json)?; + Self::from_wire(wire) + } + + fn from_wire(wire: RawWire) -> Result { + let header = wire.fields.get(HEADER_KEY).and_then(Value::as_object).ok_or(Error::NotStencilCraft)?; if header.get("name").and_then(Value::as_str) != Some(FORMAT_NAME) { return Err(Error::NotStencilCraft); } @@ -101,7 +118,7 @@ impl File { if version != FORMAT_VERSION { return Err(Error::Version { found: version, supported: FORMAT_VERSION }); } - let raw: RawFile = serde_json::from_value(value).map_err(|source| Error::Document { source })?; + let raw: RawFile = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?; Ok(Self { document: raw.document }) } @@ -127,6 +144,132 @@ struct RawFile { document: DocumentFields, } +/// A top-level streaming visitor that stores only fields owned by this wire schema. +/// +/// Values are kept as `Value` because consumers own their document models. Unknown fields are +/// skipped, duplicate fields retain serde_json's last-value-wins behavior, and header checks run +/// only after the full JSON input has been parsed. +struct RawWire { + fields: Map, +} + +impl<'de> Deserialize<'de> for RawWire { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RawWireVisitor; + + impl<'de> Visitor<'de> for RawWireVisitor { + type Value = RawWire; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value containing a StencilCraft document") + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + let mut fields = Map::new(); + while let Some(key) = access.next_key::()? { + if is_wire_field(&key) { + fields.insert(key, access.next_value()?); + } else { + let _ = access.next_value::()?; + } + } + Ok(RawWire { fields }) + } + + fn visit_seq(self, mut access: A) -> Result + where + A: SeqAccess<'de>, + { + while access.next_element::()?.is_some() {} + Ok(RawWire { fields: Map::new() }) + } + + fn visit_bool(self, _value: bool) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_i64(self, _value: i64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_u64(self, _value: u64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_f64(self, _value: f64) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_str(self, _value: &str) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_string(self, _value: String) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(RawWire { fields: Map::new() }) + } + } + + deserializer.deserialize_any(RawWireVisitor) + } +} + +fn is_wire_field(name: &str) -> bool { + matches!( + name, + HEADER_KEY + | "board" + | "kerf" + | "layers" + | "next-shape-id" + | "images" + | "board-setup" + | "pages" + | "guides" + | "grid" + | "snap" + | "groups" + | "meta" + ) +} + #[derive(Serialize)] struct HeaderRef<'a> { name: &'a str, @@ -161,6 +304,8 @@ pub enum Error { #[cfg(test)] mod tests { + use std::io::{self, Cursor, Read}; + use serde_json::{Value, json}; use super::{DocumentFields, Error, FORMAT_VERSION, File}; @@ -222,6 +367,37 @@ mod tests { assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":2}}"#), Err(Error::Version { found: 2, supported: 1 }))); } + #[test] + fn reader_preserves_duplicate_key_and_error_precedence_behavior() -> Result<(), Error> { + let duplicate = r#"{"stencilcraft":{"name":"wrong","version":1},"stencilcraft":{"name":"stencilcraft","version":1},"board":{"first":true},"board":{"width_mm":100,"height_mm":150},"kerf":{},"layers":[]}"#; + let file = File::read_from(Cursor::new(duplicate))?; + assert_eq!(file.document.board, json!({"width_mm":100,"height_mm":150})); + + let malformed_after_bad_header = Cursor::new(br#"{"stencilcraft":{"name":"other","version":1},"board": "#); + assert!(matches!(File::read_from(malformed_after_bad_header), Err(Error::Json(_)))); + assert!(matches!(File::read_from(Cursor::new(b"[]")), Err(Error::NotStencilCraft))); + Ok(()) + } + + #[test] + fn reader_accepts_large_input_in_short_reads() -> Result<(), Error> { + struct ShortReads(R); + impl Read for ShortReads { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let length = buffer.len().min(1024); + self.0.read(&mut buffer[..length]) + } + } + + let text = format!( + r#"{{"stencilcraft":{{"name":"stencilcraft","version":1}},"board":{{}},"kerf":{{}},"layers":[],"future-field":"{}"}}"#, + "x".repeat(8 * 1024 * 1024) + ); + let file = File::read_from(ShortReads(Cursor::new(text.as_bytes())))?; + assert_eq!(file.document.layers, json!([])); + Ok(()) + } + #[test] fn separates_json_errors_from_wire_schema_errors() { assert!(matches!(File::read("{"), Err(Error::Json(_))));