Skip to main content

memstead_base/validator/
archive.rs

1//! Archive-level checks over raw zip bytes.
2//!
3//! Single pass: walks every entry once, enforces safety + caps + the
4//! file-type whitelist, yields entries to downstream modules. Returns
5//! the config-file bytes separately from the markdown entries because
6//! downstream dispatch differs — config goes to `validator::config`,
7//! markdown goes to `validator::strict` + `parse_markdown`.
8
9use std::io::{Cursor, Read};
10
11use memstead_schema::{
12    ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_META_DIR, ARCHIVE_PROVENANCE_PATH,
13    ARCHIVE_SCHEMA_PREFIX,
14};
15
16use super::{SizeCapKind, ValidationError, ValidatorLimits};
17
18/// One markdown file extracted from the archive. `content` is the raw
19/// UTF-8 body; BOM stripping is deferred to the strict checker so the
20/// raw bytes for offset reporting stay truthful.
21#[derive(Debug)]
22pub struct MarkdownEntry {
23    pub path: String,
24    pub content: String,
25}
26
27/// One schema-source file extracted from the archive's
28/// `.memstead/schema/` tree.
29///
30/// `archive_path` is relative to the archive root — e.g.
31/// `".memstead/schema/schema.yaml"` or `".memstead/schema/types/spec.yaml"`.
32/// Carried as `String` so the canonical re-pack and the install-time
33/// staging side effect can write identical bytes back out (CRLF → LF
34/// normalization is applied at extract time so re-validation over
35/// `canonical_bytes` is a fixpoint).
36#[derive(Debug)]
37pub struct SchemaFile {
38    pub archive_path: String,
39    pub content: String,
40}
41
42/// Re-key an archive's schema tree to the package-relative shape every
43/// sealed schema surface speaks (`schema.yaml`, `types/<t>.yaml`,
44/// `schema-format.json`) — the form
45/// [`memstead_schema::load_sealed_package`] reads and the form the
46/// local schema source is written in. Files outside the
47/// `.memstead/schema/` tree cannot appear here (the extractor filters
48/// them), so the strip is total in practice; anything unprefixed is
49/// passed through unchanged rather than silently dropped.
50pub fn to_package_files(schema_files: &[SchemaFile]) -> Vec<(String, Vec<u8>)> {
51    schema_files
52        .iter()
53        .map(|sf| {
54            let rel = sf
55                .archive_path
56                .strip_prefix(memstead_schema::ARCHIVE_SCHEMA_PREFIX)
57                .unwrap_or(sf.archive_path.as_str());
58            (rel.to_string(), sf.content.as_bytes().to_vec())
59        })
60        .collect()
61}
62
63#[derive(Debug)]
64pub struct ArchiveEntries {
65    pub config_bytes: Vec<u8>,
66    pub markdown_files: Vec<MarkdownEntry>,
67    pub schema_files: Vec<SchemaFile>,
68    /// Raw bytes of the optional authoring-provenance payload
69    /// (`.memstead/provenance.json`), or `None` when the archive carries
70    /// none. Additive: a provenance-free archive yields `None`, and an
71    /// unrecognised future meta member is tolerated-and-ignored (never
72    /// surfaced here, never an error).
73    pub provenance_bytes: Option<Vec<u8>>,
74    /// Raw bytes of the optional engine-owned anchors sidecar
75    /// (`.memstead/anchors.json`, E3a), or `None` when the archive carries
76    /// none. Recognised as a first-class member so the canonical re-pack
77    /// threads it through verbatim rather than dropping it; a
78    /// recognised-but-structurally-invalid payload is a typed validation
79    /// failure (unlike unknown future meta members, which stay
80    /// tolerate-and-ignore).
81    pub anchors_bytes: Option<Vec<u8>>,
82}
83
84/// Walk the archive, enforce archive-level rules, return entries in
85/// sorted path order. Fails with a typed `ValidationError` on any
86/// violation. Performs no I/O; reads from the provided byte slice.
87pub fn extract_entries(
88    bytes: &[u8],
89    limits: &ValidatorLimits,
90) -> Result<ArchiveEntries, ValidationError> {
91    if bytes.len() as u64 > limits.max_compressed_archive {
92        return Err(ValidationError::SizeCapExceeded {
93            kind: SizeCapKind::CompressedArchive,
94            got: bytes.len() as u64,
95            limit: limits.max_compressed_archive,
96        });
97    }
98
99    let cursor = Cursor::new(bytes);
100    let mut archive =
101        zip::ZipArchive::new(cursor).map_err(|e| ValidationError::Zip(e.to_string()))?;
102
103    if archive.len() as u32 > limits.max_file_count {
104        return Err(ValidationError::SizeCapExceeded {
105            kind: SizeCapKind::EntryCount,
106            got: archive.len() as u64,
107            limit: limits.max_file_count as u64,
108        });
109    }
110
111    let mut config_bytes: Option<Vec<u8>> = None;
112    let mut markdown_files: Vec<MarkdownEntry> = Vec::new();
113    let mut schema_files: Vec<SchemaFile> = Vec::new();
114    let mut provenance_bytes: Option<Vec<u8>> = None;
115    let mut anchors_bytes: Option<Vec<u8>> = None;
116    let mut seen_paths: Vec<String> = Vec::new();
117    let mut uncompressed_total: u64 = 0;
118
119    for i in 0..archive.len() {
120        let mut entry = archive
121            .by_index(i)
122            .map_err(|e| ValidationError::Zip(e.to_string()))?;
123
124        if entry.is_dir() {
125            continue;
126        }
127
128        if entry.is_symlink() {
129            return Err(ValidationError::Symlink(entry.name().to_string()));
130        }
131
132        // `enclosed_name()` normalizes absolute POSIX paths (`/foo`) and
133        // Windows drive-letter prefixes (`C:\foo`) by stripping them
134        // into innocent-looking relative paths, which masks adversarial
135        // intent. Catch those shapes on the raw name first so the
136        // rejection carries the original, truthful path.
137        let raw_name = entry.name();
138        if raw_name.starts_with('/') || raw_name.starts_with('\\') {
139            return Err(ValidationError::Zip(format!(
140                "unsafe entry path: {raw_name}"
141            )));
142        }
143        let raw_bytes = raw_name.as_bytes();
144        if raw_bytes.len() >= 2 && raw_bytes[1] == b':' && raw_bytes[0].is_ascii_alphabetic() {
145            return Err(ValidationError::Zip(format!(
146                "unsafe entry path: {raw_name}"
147            )));
148        }
149
150        let enclosed = entry
151            .enclosed_name()
152            .ok_or_else(|| ValidationError::Zip(format!("unsafe entry path: {}", entry.name())))?;
153        let path_string = enclosed
154            .to_str()
155            .ok_or_else(|| ValidationError::Zip(format!("non-UTF-8 entry path: {}", entry.name())))?
156            .replace('\\', "/");
157
158        if path_string.len() > limits.max_path_length {
159            return Err(ValidationError::PathTooLong {
160                path: path_string.clone(),
161                len: path_string.len(),
162                limit: limits.max_path_length,
163            });
164        }
165
166        let depth = path_string.split('/').count();
167        if depth > limits.max_path_depth {
168            return Err(ValidationError::PathTooDeep {
169                path: path_string.clone(),
170                depth,
171                limit: limits.max_path_depth,
172            });
173        }
174
175        if seen_paths.iter().any(|p| p == &path_string) {
176            return Err(ValidationError::DuplicateEntry(path_string));
177        }
178
179        let meta_dir_prefix = format!("{ARCHIVE_META_DIR}/");
180        let is_config = path_string == ARCHIVE_CONFIG_PATH;
181        let is_schema = is_schema_path(&path_string);
182        let is_provenance = path_string == ARCHIVE_PROVENANCE_PATH;
183        let is_anchors = path_string == ARCHIVE_ANCHORS_PATH;
184        // `.md` files inside the meta dir are NOT entities — without
185        // this guard a `.memstead/notes.md` would slip past the
186        // whitelist as markdown.
187        let is_markdown =
188            path_string.ends_with(".md") && !path_string.starts_with(&meta_dir_prefix);
189        // Forward-compat: a *top-level* member under the engine-owned
190        // `.memstead/` meta dir that none of the recognised kinds claim is
191        // an additive payload a newer writer added (the next
192        // `provenance.json`-shaped file). Tolerate-and-ignore it (still
193        // size-cap-enforced below, then discarded) so a future-meta-bearing
194        // archive installs without error on an engine that does not
195        // recognise the member. Inert: never loaded or served.
196        //
197        // Deliberately narrow — the existing strict boundaries stay:
198        // a `.md` file inside the meta dir is still rejected (it must not
199        // slip past as a non-entity), and the `.memstead/schema/` subtree
200        // stays strict (an ill-formed schema member is rejected, not
201        // silently ignored, so archives can't smuggle payloads under the
202        // schema prefix). Future additive payloads live as new top-level
203        // meta files, not under the schema subtree. Members OUTSIDE the
204        // meta dir are still rejected.
205        let is_ignored_meta = path_string.starts_with(&meta_dir_prefix)
206            && !is_config
207            && !is_schema
208            && !is_provenance
209            && !is_anchors
210            && !path_string.ends_with(".md")
211            && !path_string.starts_with(ARCHIVE_SCHEMA_PREFIX);
212        if !is_config
213            && !is_markdown
214            && !is_schema
215            && !is_provenance
216            && !is_anchors
217            && !is_ignored_meta
218        {
219            return Err(ValidationError::UnknownFile(path_string));
220        }
221
222        let per_entry_cap = if is_config {
223            limits.max_config_file
224        } else {
225            limits.max_uncompressed_entry
226        };
227
228        let mut buf = Vec::new();
229        let mut reader = (&mut entry).take(per_entry_cap + 1);
230        reader
231            .read_to_end(&mut buf)
232            .map_err(|e| ValidationError::Zip(e.to_string()))?;
233
234        if buf.len() as u64 > per_entry_cap {
235            let kind = if is_config {
236                SizeCapKind::ConfigFile
237            } else {
238                SizeCapKind::UncompressedEntry
239            };
240            return Err(ValidationError::SizeCapExceeded {
241                kind,
242                got: buf.len() as u64,
243                limit: per_entry_cap,
244            });
245        }
246
247        uncompressed_total = uncompressed_total.saturating_add(buf.len() as u64);
248        if uncompressed_total > limits.max_uncompressed_archive {
249            return Err(ValidationError::SizeCapExceeded {
250                kind: SizeCapKind::UncompressedArchive,
251                got: uncompressed_total,
252                limit: limits.max_uncompressed_archive,
253            });
254        }
255
256        seen_paths.push(path_string.clone());
257
258        // An unrecognised meta member has now passed the size caps; drop
259        // it (forward-compat tolerate-and-ignore) without surfacing it.
260        if is_ignored_meta {
261            continue;
262        }
263
264        if is_config {
265            config_bytes = Some(buf);
266        } else if is_provenance {
267            // Raw bytes surfaced for the caller to parse into
268            // `ArchiveProvenance`; the validator does not interpret the
269            // payload (a malformed payload is the install path's call to
270            // downgrade to "provenance absent", not an archive-shape error).
271            provenance_bytes = Some(buf);
272        } else if is_anchors {
273            // Strict, unlike provenance: a recognised anchors member that
274            // does not parse as an `AnchorSidecar` is corruption, not
275            // forward-compat — silently dropping it is exactly the
276            // publish-strip failure E3a exists to close. Validate the
277            // structure here; thread the verbatim bytes through the
278            // canonical re-pack on success.
279            crate::anchor::AnchorSidecar::from_bytes(&buf).map_err(|e| {
280                ValidationError::InvalidAnchorsMember {
281                    reason: e.to_string(),
282                }
283            })?;
284            anchors_bytes = Some(buf);
285        } else {
286            let content = match std::str::from_utf8(&buf) {
287                Ok(s) => s.to_string(),
288                Err(e) => {
289                    return Err(ValidationError::Utf8 {
290                        path: path_string,
291                        offset: e.valid_up_to(),
292                    });
293                }
294            };
295            // Normalize CRLF → LF so every downstream pass (strict
296            // checker, parse_markdown, generate_markdown, canonical
297            // re-pack) sees the same bytes regardless of the
298            // publisher's editor. Without this, two semantically
299            // identical archives produce different canonical_bytes.
300            let content = content.replace("\r\n", "\n");
301            if is_schema {
302                schema_files.push(SchemaFile {
303                    archive_path: path_string,
304                    content,
305                });
306            } else {
307                markdown_files.push(MarkdownEntry {
308                    path: path_string,
309                    content,
310                });
311            }
312        }
313    }
314
315    let config_bytes = config_bytes.ok_or(ValidationError::MissingConfig)?;
316
317    markdown_files.sort_by(|a, b| a.path.cmp(&b.path));
318    schema_files.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
319
320    Ok(ArchiveEntries {
321        config_bytes,
322        markdown_files,
323        schema_files,
324        provenance_bytes,
325        anchors_bytes,
326    })
327}
328
329/// Recognize the archive-side layout of an embedded schema package:
330/// the manifest (`.memstead/schema/schema.yaml`) and per-type files
331/// (`.memstead/schema/types/<stem>.yaml`). Matching the loader's
332/// on-disk layout keeps embed + extract symmetric — anything outside
333/// this shape is rejected as an unknown file so archives can't smuggle
334/// arbitrary payloads past the whitelist under a schema prefix.
335fn is_schema_path(path: &str) -> bool {
336    let Some(rest) = path.strip_prefix(ARCHIVE_SCHEMA_PREFIX) else {
337        return false;
338    };
339    if rest == "schema.yaml" {
340        return true;
341    }
342    // The sealed format marker rides with the package so a published
343    // archive keeps its metadata-polarity generation.
344    if rest == memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE {
345        return true;
346    }
347    let Some(rest) = rest.strip_prefix("types/") else {
348        return false;
349    };
350    if !rest.ends_with(".yaml") {
351        return false;
352    }
353    let stem = &rest[..rest.len() - ".yaml".len()];
354    !stem.is_empty() && !stem.contains('/')
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use std::io::Write;
361    use zip::write::SimpleFileOptions;
362
363    /// Minimal archive builder for tests. Writes a DEFLATE-compressed
364    /// zip with the given entries to a Vec<u8>.
365    fn build_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
366        let mut buf = Vec::new();
367        {
368            let cursor = Cursor::new(&mut buf);
369            let mut w = zip::ZipWriter::new(cursor);
370            let options =
371                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
372            for (name, content) in entries {
373                w.start_file(*name, options).unwrap();
374                w.write_all(content).unwrap();
375            }
376            w.finish().unwrap();
377        }
378        buf
379    }
380
381    fn ok_config() -> &'static [u8] {
382        br#"{"format":3,"name":"v","version":"0.1.0","schema":"default@1.0.0"}"#
383    }
384
385    #[test]
386    fn accepts_minimal_valid_archive() {
387        let zip = build_archive(&[
388            (".memstead/config.json", ok_config()),
389            ("foo.md", b"# Foo\n"),
390        ]);
391        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
392        assert_eq!(entries.markdown_files.len(), 1);
393        assert_eq!(entries.markdown_files[0].path, "foo.md");
394        // A provenance-free archive surfaces no provenance — absent, not
395        // an error.
396        assert!(entries.provenance_bytes.is_none());
397    }
398
399    /// The engine-owned anchors sidecar (`.memstead/anchors.json`) is a
400    /// recognised member surfaced verbatim — not stripped as an unknown
401    /// meta member, not mistaken for an entity.
402    #[test]
403    fn recognises_valid_anchors_member() {
404        let anchors = br#"{"version":1,"entities":{"v--foo":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
405        let zip = build_archive(&[
406            (".memstead/config.json", ok_config()),
407            (".memstead/anchors.json", anchors),
408            ("foo.md", b"# Foo\n"),
409        ]);
410        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
411        assert_eq!(
412            entries.anchors_bytes.as_deref(),
413            Some(&anchors[..]),
414            "anchors bytes surface verbatim"
415        );
416        assert_eq!(entries.markdown_files.len(), 1, "anchors is not an entity");
417    }
418
419    /// A recognised-but-structurally-invalid anchors member is a typed
420    /// failure — silent drop is exactly the publish-strip failure E3a
421    /// closes. (Unknown OTHER meta members keep tolerate-and-ignore.)
422    #[test]
423    fn rejects_malformed_anchors_member() {
424        let zip = build_archive(&[
425            (".memstead/config.json", ok_config()),
426            (".memstead/anchors.json", b"{ this is not valid json"),
427            ("foo.md", b"# Foo\n"),
428        ]);
429        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
430        assert!(
431            matches!(err, ValidationError::InvalidAnchorsMember { .. }),
432            "expected InvalidAnchorsMember, got {err:?}"
433        );
434    }
435
436    /// The optional `.memstead/provenance.json` payload is recognised and
437    /// surfaced verbatim on `ArchiveEntries` — not rejected as an unknown
438    /// file, not mistaken for a markdown entity (it lives under the meta
439    /// dir).
440    #[test]
441    fn recognises_provenance_member() {
442        let prov = br#"{"format":1,"history":"summarised","entities":{}}"#;
443        let zip = build_archive(&[
444            (".memstead/config.json", ok_config()),
445            (".memstead/provenance.json", prov),
446            ("foo.md", b"# Foo\n"),
447        ]);
448        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
449        assert_eq!(
450            entries.provenance_bytes.as_deref(),
451            Some(&prov[..]),
452            "provenance bytes surface verbatim"
453        );
454        assert_eq!(
455            entries.markdown_files.len(),
456            1,
457            "provenance is not an entity"
458        );
459    }
460
461    /// Forward-compat: an unrecognised member under the engine-owned
462    /// `.memstead/` meta dir (a payload a newer writer added) is tolerated
463    /// and ignored — the archive still extracts cleanly, so a future-meta-
464    /// bearing archive installs without error on an engine that does not
465    /// know the member. This is the additive contract the provenance
466    /// payload relies on. An unknown member OUTSIDE the meta dir is still
467    /// rejected.
468    #[test]
469    fn tolerates_unknown_meta_member_but_rejects_unknown_root_member() {
470        let tolerated = build_archive(&[
471            (".memstead/config.json", ok_config()),
472            (".memstead/future-payload.json", br#"{"x":1}"#),
473            ("foo.md", b"# Foo\n"),
474        ]);
475        let entries = extract_entries(&tolerated, &ValidatorLimits::DEFAULT)
476            .expect("unknown meta member must be tolerated");
477        assert_eq!(entries.markdown_files.len(), 1);
478        assert!(
479            entries.provenance_bytes.is_none(),
480            "an unrecognised meta member is ignored, not surfaced as provenance"
481        );
482
483        let rejected = build_archive(&[
484            (".memstead/config.json", ok_config()),
485            ("stray.txt", b"not allowed at root"),
486            ("foo.md", b"# Foo\n"),
487        ]);
488        let err = extract_entries(&rejected, &ValidatorLimits::DEFAULT).unwrap_err();
489        assert!(
490            matches!(err, ValidationError::UnknownFile(ref p) if p == "stray.txt"),
491            "unknown non-meta member must still be rejected, got {err:?}"
492        );
493    }
494
495    /// A meta member under a foreign (non-`.memstead/`) dir alongside a
496    /// current `.memstead/` config is rejected — it falls outside the
497    /// whitelist. Guards that only `.memstead/` is the tolerated layout.
498    #[test]
499    fn rejects_mixed_meta_dir_layout() {
500        let zip = build_archive(&[
501            (".memstead/config.json", ok_config()),
502            (".other/schema/schema.yaml", b"name: default\n"),
503            ("foo.md", b"# Foo\n"),
504        ]);
505        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
506        match err {
507            ValidationError::UnknownFile(path) => {
508                assert!(path.starts_with(".other/"), "path={path}");
509            }
510            other => panic!("expected UnknownFile for the foreign meta member, got {other:?}"),
511        }
512    }
513
514    /// `.md` files inside the meta dir are engine-internal, not
515    /// entities — they must not pass the whitelist as markdown.
516    #[test]
517    fn rejects_markdown_inside_meta_dir() {
518        let zip = build_archive(&[
519            (".memstead/config.json", ok_config()),
520            (".memstead/notes.md", b"# not an entity\n"),
521        ]);
522        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
523        assert!(
524            matches!(err, ValidationError::UnknownFile(_)),
525            "got {err:?}"
526        );
527    }
528
529    #[test]
530    fn markdown_files_are_sorted() {
531        let zip = build_archive(&[
532            (".memstead/config.json", ok_config()),
533            ("z.md", b"# Z\n"),
534            ("a.md", b"# A\n"),
535            ("m.md", b"# M\n"),
536        ]);
537        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
538        let paths: Vec<_> = entries
539            .markdown_files
540            .iter()
541            .map(|e| e.path.as_str())
542            .collect();
543        assert_eq!(paths, vec!["a.md", "m.md", "z.md"]);
544    }
545
546    #[test]
547    fn rejects_corrupt_zip() {
548        let err = extract_entries(b"not a zip at all", &ValidatorLimits::DEFAULT).unwrap_err();
549        assert!(matches!(err, ValidationError::Zip(_)));
550    }
551
552    #[test]
553    fn rejects_missing_config() {
554        let zip = build_archive(&[("foo.md", b"# Foo\n")]);
555        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
556        assert!(matches!(err, ValidationError::MissingConfig));
557    }
558
559    #[test]
560    fn rejects_non_markdown_non_config_file() {
561        let zip = build_archive(&[
562            (".memstead/config.json", ok_config()),
563            ("binary.exe", b"\x7fELF"),
564        ]);
565        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
566        match err {
567            ValidationError::UnknownFile(p) => assert_eq!(p, "binary.exe"),
568            other => panic!("expected UnknownFile, got {other:?}"),
569        }
570    }
571
572    #[test]
573    fn accepts_schema_package_entries() {
574        // The whitelist must admit the two shapes `export_mem` embeds:
575        // the manifest at `.memstead/schema/schema.yaml` and per-type YAMLs
576        // under `.memstead/schema/types/`. Anything else under
577        // `.memstead/schema/` is still an unknown file (asserted separately).
578        let zip = build_archive(&[
579            (".memstead/config.json", ok_config()),
580            ("foo.md", b"# Foo\n"),
581            (".memstead/schema/schema.yaml", b"name: default\n"),
582            (".memstead/schema/types/spec.yaml", b"name: spec\n"),
583        ]);
584        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
585        assert_eq!(entries.schema_files.len(), 2);
586        let paths: Vec<&str> = entries
587            .schema_files
588            .iter()
589            .map(|s| s.archive_path.as_str())
590            .collect();
591        assert_eq!(
592            paths,
593            vec![
594                ".memstead/schema/schema.yaml",
595                ".memstead/schema/types/spec.yaml"
596            ]
597        );
598    }
599
600    /// The sealed format marker rides the archive: it is admitted by
601    /// the whitelist and lands in `schema_files` so the loaders can
602    /// honor the package's metadata-polarity generation.
603    #[test]
604    fn accepts_schema_format_marker() {
605        let zip = build_archive(&[
606            (".memstead/config.json", ok_config()),
607            ("foo.md", b"# Foo\n"),
608            (".memstead/schema/schema.yaml", b"name: default\n"),
609            (
610                ".memstead/schema/schema-format.json",
611                b"{\"metadata_polarity\":\"required-opt-in\"}\n",
612            ),
613        ]);
614        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
615        assert!(
616            entries
617                .schema_files
618                .iter()
619                .any(|s| s.archive_path == ".memstead/schema/schema-format.json"),
620            "marker must ride in schema_files"
621        );
622    }
623
624    #[test]
625    fn rejects_unknown_schema_subpath() {
626        // Anything under `.memstead/schema/` that isn't the manifest or a
627        // type file must still be rejected — otherwise archives could
628        // smuggle arbitrary payloads behind the whitelist.
629        let zip = build_archive(&[
630            (".memstead/config.json", ok_config()),
631            (".memstead/schema/unexpected.json", b"{}"),
632        ]);
633        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
634        assert!(matches!(err, ValidationError::UnknownFile(_)));
635    }
636
637    #[test]
638    fn rejects_nested_schema_type_file() {
639        let zip = build_archive(&[
640            (".memstead/config.json", ok_config()),
641            (
642                ".memstead/schema/types/nested/subtype.yaml",
643                b"name: subtype\n",
644            ),
645        ]);
646        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
647        assert!(matches!(err, ValidationError::UnknownFile(_)));
648    }
649
650    /// An unknown root-level file (not part of the whitelist) is
651    /// rejected as an unknown file — no special-casing survives.
652    #[test]
653    fn rejects_unknown_root_file() {
654        let zip = build_archive(&[
655            (".memstead/config.json", ok_config()),
656            (
657                "some-root.json",
658                br#"{"format":3,"name":"v","version":"0.1.0"}"#,
659            ),
660        ]);
661        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
662        assert!(
663            matches!(err, ValidationError::UnknownFile(ref p) if p == "some-root.json"),
664            "unknown root file must be rejected as UnknownFile, got {err:?}"
665        );
666    }
667
668    #[test]
669    fn rejects_zip_slip() {
670        let zip = build_archive(&[
671            (".memstead/config.json", ok_config()),
672            ("../escape.md", b"# Escape\n"),
673        ]);
674        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
675        assert!(matches!(err, ValidationError::Zip(_)));
676    }
677
678    #[test]
679    fn rejects_nested_zip_slip() {
680        let zip = build_archive(&[
681            (".memstead/config.json", ok_config()),
682            ("subdir/../../escape.md", b"# Escape\n"),
683        ]);
684        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
685        assert!(matches!(err, ValidationError::Zip(_)));
686    }
687
688    // Raw zip-bytes helpers below exercise rejection paths that the
689    // cooperative `ZipWriter` API normalizes away or refuses to emit:
690    // duplicate filenames, absolute paths, drive letters, symlinks.
691
692    /// Minimal CRC32/ISO-HDLC — zip expects this exact polynomial in
693    /// both the local file header and the central directory entry.
694    /// Inlined to avoid a dev-dependency just for the raw-zip helper.
695    fn crc32(data: &[u8]) -> u32 {
696        let mut crc = !0u32;
697        for &b in data {
698            crc ^= b as u32;
699            for _ in 0..8 {
700                let mask = (crc & 1).wrapping_neg();
701                crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
702            }
703        }
704        !crc
705    }
706
707    /// Hand-crafts a stored-only (uncompressed) zip with arbitrary
708    /// entry names — including shapes that `ZipWriter` rejects or
709    /// rewrites. Produces a structurally valid archive the `zip` crate
710    /// can open; validator-level checks are the ones doing the rejecting.
711    fn raw_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
712        let mut out: Vec<u8> = Vec::new();
713        let mut central: Vec<u8> = Vec::new();
714        let mut count: u16 = 0;
715
716        for (name, content) in entries {
717            let name_bytes = name.as_bytes();
718            let crc = crc32(content);
719            let size = content.len() as u32;
720            let offset = out.len() as u32;
721
722            // Local file header (PK\3\4)
723            out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
724            out.extend_from_slice(&10u16.to_le_bytes()); // version needed
725            out.extend_from_slice(&0u16.to_le_bytes()); // flags
726            out.extend_from_slice(&0u16.to_le_bytes()); // stored
727            out.extend_from_slice(&0u16.to_le_bytes()); // mtime
728            out.extend_from_slice(&0u16.to_le_bytes()); // mdate
729            out.extend_from_slice(&crc.to_le_bytes());
730            out.extend_from_slice(&size.to_le_bytes()); // compressed
731            out.extend_from_slice(&size.to_le_bytes()); // uncompressed
732            out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
733            out.extend_from_slice(&0u16.to_le_bytes()); // extra length
734            out.extend_from_slice(name_bytes);
735            out.extend_from_slice(content);
736
737            // Central directory file header (PK\1\2)
738            central.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
739            central.extend_from_slice(&20u16.to_le_bytes()); // version made by
740            central.extend_from_slice(&10u16.to_le_bytes()); // version needed
741            central.extend_from_slice(&0u16.to_le_bytes()); // flags
742            central.extend_from_slice(&0u16.to_le_bytes()); // stored
743            central.extend_from_slice(&0u16.to_le_bytes()); // mtime
744            central.extend_from_slice(&0u16.to_le_bytes()); // mdate
745            central.extend_from_slice(&crc.to_le_bytes());
746            central.extend_from_slice(&size.to_le_bytes());
747            central.extend_from_slice(&size.to_le_bytes());
748            central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
749            central.extend_from_slice(&0u16.to_le_bytes()); // extra
750            central.extend_from_slice(&0u16.to_le_bytes()); // comment
751            central.extend_from_slice(&0u16.to_le_bytes()); // disk number
752            central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
753            central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
754            central.extend_from_slice(&offset.to_le_bytes()); // LFH offset
755            central.extend_from_slice(name_bytes);
756
757            count += 1;
758        }
759
760        let cd_offset = out.len() as u32;
761        let cd_size = central.len() as u32;
762        out.extend_from_slice(&central);
763
764        // End of central directory (PK\5\6)
765        out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
766        out.extend_from_slice(&0u16.to_le_bytes()); // disk number
767        out.extend_from_slice(&0u16.to_le_bytes()); // disk with CD start
768        out.extend_from_slice(&count.to_le_bytes()); // entries this disk
769        out.extend_from_slice(&count.to_le_bytes()); // entries total
770        out.extend_from_slice(&cd_size.to_le_bytes());
771        out.extend_from_slice(&cd_offset.to_le_bytes());
772        out.extend_from_slice(&0u16.to_le_bytes()); // comment length
773
774        out
775    }
776
777    // Duplicate-entry path: verified empirically with `raw_zip` that
778    // the zip 8 reader itself collapses duplicates during central-
779    // directory parse (only the last entry survives, `archive.len()`
780    // returns 1). The `seen_paths` guard in `extract_entries` is
781    // therefore only reachable if a future zip-crate release preserves
782    // duplicates — kept as defense-in-depth, cannot be exercised in a
783    // unit test against current zip 8.x.
784
785    #[test]
786    fn rejects_absolute_path_entry() {
787        let zip = raw_zip(&[
788            (".memstead/config.json", ok_config()),
789            ("/etc/passwd.md", b"# Escape\n"),
790        ]);
791        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
792        // `enclosed_name()` refuses absolute paths → validator maps to
793        // `Zip` with the unsafe-entry reason.
794        match err {
795            ValidationError::Zip(reason) => {
796                assert!(
797                    reason.contains("unsafe entry path"),
798                    "unexpected reason: {reason}"
799                );
800            }
801            other => panic!("expected Zip(unsafe entry), got {other:?}"),
802        }
803    }
804
805    #[test]
806    fn rejects_windows_drive_letter_entry() {
807        // Backslashes are allowed inside zip member names on Windows-
808        // produced archives; `enclosed_name()` normalizes separators
809        // and then refuses drive-prefixed absolute paths.
810        let zip = raw_zip(&[
811            (".memstead/config.json", ok_config()),
812            ("C:\\evil.md", b"# Escape\n"),
813        ]);
814        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
815        assert!(matches!(err, ValidationError::Zip(_)));
816    }
817
818    #[test]
819    fn rejects_symlink_entry() {
820        // The cooperative writer exposes `add_symlink`, which is the
821        // only path that sets the external-attributes bit the reader
822        // interprets as `is_symlink() == true`. Defense-in-depth test
823        // for R5: even if an adversary smuggles a symlink past the
824        // whitelist, the structural check fires.
825        let mut buf: Vec<u8> = Vec::new();
826        {
827            let cursor = Cursor::new(&mut buf);
828            let mut w = zip::ZipWriter::new(cursor);
829            w.start_file(".memstead/config.json", SimpleFileOptions::default())
830                .unwrap();
831            w.write_all(ok_config()).unwrap();
832            w.add_symlink("link.md", "target.md", SimpleFileOptions::default())
833                .unwrap();
834            w.finish().unwrap();
835        }
836        let err = extract_entries(&buf, &ValidatorLimits::DEFAULT).unwrap_err();
837        match err {
838            ValidationError::Symlink(name) => assert_eq!(name, "link.md"),
839            other => panic!("expected Symlink, got {other:?}"),
840        }
841    }
842
843    #[test]
844    fn rejects_compressed_archive_too_large() {
845        let mut limits = ValidatorLimits::DEFAULT;
846        limits.max_compressed_archive = 64;
847        let zip = build_archive(&[
848            (".memstead/config.json", ok_config()),
849            ("foo.md", b"# Foo\n"),
850        ]);
851        let err = extract_entries(&zip, &limits).unwrap_err();
852        assert!(matches!(
853            err,
854            ValidationError::SizeCapExceeded {
855                kind: SizeCapKind::CompressedArchive,
856                ..
857            }
858        ));
859    }
860
861    #[test]
862    fn rejects_single_entry_too_large() {
863        let mut limits = ValidatorLimits::DEFAULT;
864        limits.max_uncompressed_entry = 10;
865        let zip = build_archive(&[
866            (".memstead/config.json", ok_config()),
867            ("big.md", &[b'x'; 100]),
868        ]);
869        let err = extract_entries(&zip, &limits).unwrap_err();
870        assert!(matches!(
871            err,
872            ValidationError::SizeCapExceeded {
873                kind: SizeCapKind::UncompressedEntry,
874                ..
875            }
876        ));
877    }
878
879    #[test]
880    fn rejects_config_file_too_large() {
881        let mut limits = ValidatorLimits::DEFAULT;
882        limits.max_config_file = 10;
883        let zip = build_archive(&[
884            (".memstead/config.json", &[b'x'; 50]),
885            ("foo.md", b"# Foo\n"),
886        ]);
887        let err = extract_entries(&zip, &limits).unwrap_err();
888        assert!(matches!(
889            err,
890            ValidationError::SizeCapExceeded {
891                kind: SizeCapKind::ConfigFile,
892                ..
893            }
894        ));
895    }
896
897    #[test]
898    fn rejects_uncompressed_sum_too_large() {
899        let mut limits = ValidatorLimits::DEFAULT;
900        limits.max_uncompressed_archive = 30;
901        let zip = build_archive(&[
902            (".memstead/config.json", ok_config()),
903            ("a.md", &[b'x'; 20]),
904            ("b.md", &[b'x'; 20]),
905        ]);
906        let err = extract_entries(&zip, &limits).unwrap_err();
907        assert!(matches!(
908            err,
909            ValidationError::SizeCapExceeded {
910                kind: SizeCapKind::UncompressedArchive,
911                ..
912            }
913        ));
914    }
915
916    #[test]
917    fn rejects_entry_count_too_large() {
918        let mut limits = ValidatorLimits::DEFAULT;
919        limits.max_file_count = 2;
920        let zip = build_archive(&[
921            (".memstead/config.json", ok_config()),
922            ("a.md", b"# A\n"),
923            ("b.md", b"# B\n"),
924        ]);
925        let err = extract_entries(&zip, &limits).unwrap_err();
926        assert!(matches!(
927            err,
928            ValidationError::SizeCapExceeded {
929                kind: SizeCapKind::EntryCount,
930                ..
931            }
932        ));
933    }
934
935    #[test]
936    fn rejects_path_too_long() {
937        let mut limits = ValidatorLimits::DEFAULT;
938        limits.max_path_length = 10;
939        let long_name = format!("{}.md", "a".repeat(20));
940        let zip = build_archive(&[
941            (".memstead/config.json", ok_config()),
942            (long_name.as_str(), b"# x\n"),
943        ]);
944        let err = extract_entries(&zip, &limits).unwrap_err();
945        assert!(matches!(err, ValidationError::PathTooLong { .. }));
946    }
947
948    #[test]
949    fn rejects_path_too_deep() {
950        let mut limits = ValidatorLimits::DEFAULT;
951        limits.max_path_depth = 2;
952        let zip = build_archive(&[
953            (".memstead/config.json", ok_config()),
954            ("a/b/c/d.md", b"# x\n"),
955        ]);
956        let err = extract_entries(&zip, &limits).unwrap_err();
957        assert!(matches!(err, ValidationError::PathTooDeep { .. }));
958    }
959
960    #[test]
961    fn rejects_non_utf8_markdown_content() {
962        let zip = build_archive(&[
963            (".memstead/config.json", ok_config()),
964            ("bad.md", &[0xff, 0xfe, 0xff]),
965        ]);
966        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
967        assert!(matches!(err, ValidationError::Utf8 { .. }));
968    }
969}