Skip to main content

memstead_base/validator/
canonical.rs

1//! Deterministic re-packing of a validated archive.
2//!
3//! LF line endings, canonical-form markdown (via
4//! `entity::generator::generate_markdown`), custom canonical JSON for
5//! the config (sorted keys, two-space indent), and a zip with sorted
6//! entries, fixed mtime (1980-01-01), DEFLATE level 6. Pinned by
7//! golden-file tests so any serialization change breaks CI.
8
9use std::io::{Cursor, Write};
10use std::sync::Arc;
11
12use memstead_schema::{ARCHIVE_CONFIG_PATH, PublishedMemConfig, Schema, type_by_name};
13use zip::CompressionMethod;
14use zip::DateTime;
15use zip::write::SimpleFileOptions;
16
17use super::ValidationError;
18use super::archive::SchemaFile;
19use crate::entity::generator::generate_markdown;
20use crate::entity::{Entity, id::id_to_file_path};
21
22/// Take the validated entities + config and produce a deterministic
23/// zip. Markdown is regenerated via `generate_markdown` (already
24/// schema-ordered and parse-stable); config is serialized via the
25/// custom canonical JSON formatter in this module; embedded schema
26/// files are written back verbatim (they were CRLF-normalized at
27/// archive-extract time). Every entry is emitted in sorted path order
28/// with fixed mtime so two semantically equal archives produce
29/// identical bytes.
30pub fn canonical_bytes(
31    config: &PublishedMemConfig,
32    entities: &[Entity],
33    schema_files: &[SchemaFile],
34    embedded_schema: Option<&Arc<Schema>>,
35    provenance_bytes: Option<&[u8]>,
36    anchors_bytes: Option<&[u8]>,
37) -> Result<Vec<u8>, ValidationError> {
38    let mut files: Vec<(String, Vec<u8>)> =
39        Vec::with_capacity(entities.len() + schema_files.len() + 3);
40
41    let config_text = canonical_json(config)?;
42    files.push((ARCHIVE_CONFIG_PATH.to_string(), config_text.into_bytes()));
43
44    // Preserve the optional authoring-provenance payload verbatim so a
45    // normalized archive round-trips it (normalize must not silently drop
46    // provenance). Carried as raw bytes — canonical re-pack does not
47    // reinterpret the payload; the producer wrote it canonically.
48    if let Some(prov) = provenance_bytes {
49        files.push((
50            memstead_schema::ARCHIVE_PROVENANCE_PATH.to_string(),
51            prov.to_vec(),
52        ));
53    }
54
55    // Preserve the optional engine-owned anchors sidecar verbatim so
56    // publish/normalize round-trips E3a provenance anchors (the whole point
57    // of recognising the member — silent strip is the failure this closes).
58    // The member was structurally validated at extract time.
59    if let Some(anchors) = anchors_bytes {
60        files.push((
61            memstead_schema::ARCHIVE_ANCHORS_PATH.to_string(),
62            anchors.to_vec(),
63        ));
64    }
65
66    // Schema files ride along verbatim. The on-extract CRLF→LF pass
67    // guarantees idempotency: a canonical archive re-validated produces
68    // byte-identical canonical bytes. Resorting happens below.
69    for sf in schema_files {
70        files.push((sf.archive_path.clone(), sf.content.clone().into_bytes()));
71    }
72
73    for entity in entities {
74        // Prefer the archive-embedded schema so user-defined types
75        // regenerate against the publisher's own rules, not the
76        // builtin-default table.
77        let schema = embedded_schema
78            .and_then(|s| s.get_type(&entity.entity_type))
79            .or_else(|| type_by_name(&entity.entity_type))
80            .ok_or_else(|| {
81                ValidationError::GraphConstructionFailed(format!(
82                    "entity {} references unresolved type {:?}",
83                    entity.id.as_ref(),
84                    entity.entity_type
85                ))
86            })?;
87        let md = generate_markdown(entity, &schema);
88        let lf = normalize_lf(&md);
89        let path = id_to_file_path(&entity.id);
90        files.push((path, lf.into_bytes()));
91    }
92
93    files.sort_by(|a, b| a.0.cmp(&b.0));
94
95    let mut buf = Vec::new();
96    {
97        let cursor = Cursor::new(&mut buf);
98        let mut writer = zip::ZipWriter::new(cursor);
99        let options = SimpleFileOptions::default()
100            .compression_method(CompressionMethod::Deflated)
101            .compression_level(Some(6))
102            .last_modified_time(DateTime::default());
103
104        for (path, content) in files {
105            writer
106                .start_file(&path, options)
107                .map_err(|e| ValidationError::GraphConstructionFailed(e.to_string()))?;
108            writer
109                .write_all(&content)
110                .map_err(|e| ValidationError::GraphConstructionFailed(e.to_string()))?;
111        }
112        writer
113            .finish()
114            .map_err(|e| ValidationError::GraphConstructionFailed(e.to_string()))?;
115    }
116
117    Ok(buf)
118}
119
120fn normalize_lf(s: &str) -> String {
121    s.replace("\r\n", "\n")
122}
123
124/// Serialize a `PublishedMemConfig` as canonical JSON: sorted keys,
125/// two-space indent, LF line endings, trailing `\n`. Uses
126/// serde_json's default string-escape rules (so behavior matches the
127/// parser's tolerance).
128pub fn canonical_json(config: &PublishedMemConfig) -> Result<String, ValidationError> {
129    let value = serde_json::to_value(config).map_err(|e| ValidationError::InvalidConfig {
130        reason: e.to_string(),
131    })?;
132    let mut out = String::new();
133    write_canonical(&value, &mut out, 0);
134    out.push('\n');
135    Ok(out)
136}
137
138fn write_canonical(value: &serde_json::Value, out: &mut String, indent: usize) {
139    match value {
140        serde_json::Value::Null => out.push_str("null"),
141        serde_json::Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
142        serde_json::Value::Number(n) => out.push_str(&n.to_string()),
143        serde_json::Value::String(s) => {
144            // Delegate string escaping to serde_json for consistency
145            // with every other JSON reader. serde_json::to_string wraps
146            // the string in double quotes, which is what we want.
147            let escaped = serde_json::to_string(s).expect("string escape infallible");
148            out.push_str(&escaped);
149        }
150        serde_json::Value::Array(items) => {
151            if items.is_empty() {
152                out.push_str("[]");
153                return;
154            }
155            out.push('[');
156            for (i, item) in items.iter().enumerate() {
157                out.push('\n');
158                push_indent(out, indent + 1);
159                write_canonical(item, out, indent + 1);
160                if i < items.len() - 1 {
161                    out.push(',');
162                }
163            }
164            out.push('\n');
165            push_indent(out, indent);
166            out.push(']');
167        }
168        serde_json::Value::Object(map) => {
169            if map.is_empty() {
170                out.push_str("{}");
171                return;
172            }
173            let mut keys: Vec<&String> = map.keys().collect();
174            keys.sort();
175            out.push('{');
176            for (i, key) in keys.iter().enumerate() {
177                out.push('\n');
178                push_indent(out, indent + 1);
179                let key_escaped = serde_json::to_string(*key).expect("string escape infallible");
180                out.push_str(&key_escaped);
181                out.push_str(": ");
182                write_canonical(&map[*key], out, indent + 1);
183                if i < keys.len() - 1 {
184                    out.push(',');
185                }
186            }
187            out.push('\n');
188            push_indent(out, indent);
189            out.push('}');
190        }
191    }
192}
193
194fn push_indent(out: &mut String, indent: usize) {
195    for _ in 0..indent * 2 {
196        out.push(' ');
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use semver::Version;
204
205    fn config() -> PublishedMemConfig {
206        PublishedMemConfig {
207            format: memstead_schema::PUBLISHED_MEM_FORMAT,
208            name: "example".to_string(),
209            version: Version::parse("0.1.0").unwrap(),
210            description: Some("a test mem".to_string()),
211            title: None,
212            subject: None,
213            authors: Some(vec!["Alice".to_string(), "Bob".to_string()]),
214            schema: "default@1.0.0".parse().unwrap(),
215        }
216    }
217
218    #[test]
219    fn canonical_json_is_alpha_sorted() {
220        let json = canonical_json(&config()).unwrap();
221        // Top-level keys in sorted order: authors, description, format, name, schema, version
222        let expected = "{\n  \"authors\": [\n    \"Alice\",\n    \"Bob\"\n  ],\n  \"description\": \"a test mem\",\n  \"format\": 4,\n  \"name\": \"example\",\n  \"schema\": \"default@1.0.0\",\n  \"version\": \"0.1.0\"\n}\n";
223        assert_eq!(json, expected);
224    }
225
226    #[test]
227    fn canonical_json_handles_empty_array_inline() {
228        let mut c = config();
229        c.authors = Some(Vec::new());
230        let json = canonical_json(&c).unwrap();
231        assert!(json.contains("\"authors\": []"));
232    }
233
234    #[test]
235    fn canonical_json_ends_with_lf() {
236        let json = canonical_json(&config()).unwrap();
237        assert!(json.ends_with('\n'));
238        assert!(!json.ends_with("\r\n"));
239    }
240
241    #[test]
242    fn canonical_json_escapes_quotes_in_strings() {
243        let mut c = config();
244        c.description = Some("he said \"hi\"".to_string());
245        let json = canonical_json(&c).unwrap();
246        assert!(json.contains("\"he said \\\"hi\\\"\""));
247    }
248
249    #[test]
250    fn canonical_bytes_produces_deterministic_output() {
251        // Same config + empty entities + empty schema files twice → identical
252        // bytes.
253        let c = config();
254        let a = canonical_bytes(&c, &[], &[], None, None, None).unwrap();
255        let b = canonical_bytes(&c, &[], &[], None, None, None).unwrap();
256        assert_eq!(a, b);
257    }
258
259    #[test]
260    fn normalize_lf_converts_crlf() {
261        assert_eq!(normalize_lf("a\r\nb\r\nc"), "a\nb\nc");
262        assert_eq!(normalize_lf("a\nb\nc"), "a\nb\nc");
263        // Lone CR stays; not our concern today.
264        assert_eq!(normalize_lf("a\rb"), "a\rb");
265    }
266}