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