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    // The sealed format marker rides with the package so a published
322    // archive keeps its metadata-polarity generation.
323    if rest == memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE {
324        return true;
325    }
326    let Some(rest) = rest.strip_prefix("types/") else {
327        return false;
328    };
329    if !rest.ends_with(".yaml") {
330        return false;
331    }
332    let stem = &rest[..rest.len() - ".yaml".len()];
333    !stem.is_empty() && !stem.contains('/')
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::io::Write;
340    use zip::write::SimpleFileOptions;
341
342    /// Minimal archive builder for tests. Writes a DEFLATE-compressed
343    /// zip with the given entries to a Vec<u8>.
344    fn build_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
345        let mut buf = Vec::new();
346        {
347            let cursor = Cursor::new(&mut buf);
348            let mut w = zip::ZipWriter::new(cursor);
349            let options =
350                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
351            for (name, content) in entries {
352                w.start_file(*name, options).unwrap();
353                w.write_all(content).unwrap();
354            }
355            w.finish().unwrap();
356        }
357        buf
358    }
359
360    fn ok_config() -> &'static [u8] {
361        br#"{"format":3,"name":"v","version":"0.1.0","schema":"default@1.0.0"}"#
362    }
363
364    #[test]
365    fn accepts_minimal_valid_archive() {
366        let zip = build_archive(&[
367            (".memstead/config.json", ok_config()),
368            ("foo.md", b"# Foo\n"),
369        ]);
370        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
371        assert_eq!(entries.markdown_files.len(), 1);
372        assert_eq!(entries.markdown_files[0].path, "foo.md");
373        // A provenance-free archive surfaces no provenance — absent, not
374        // an error.
375        assert!(entries.provenance_bytes.is_none());
376    }
377
378    /// The engine-owned anchors sidecar (`.memstead/anchors.json`) is a
379    /// recognised member surfaced verbatim — not stripped as an unknown
380    /// meta member, not mistaken for an entity.
381    #[test]
382    fn recognises_valid_anchors_member() {
383        let anchors = br#"{"version":1,"entities":{"v--foo":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
384        let zip = build_archive(&[
385            (".memstead/config.json", ok_config()),
386            (".memstead/anchors.json", anchors),
387            ("foo.md", b"# Foo\n"),
388        ]);
389        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
390        assert_eq!(
391            entries.anchors_bytes.as_deref(),
392            Some(&anchors[..]),
393            "anchors bytes surface verbatim"
394        );
395        assert_eq!(entries.markdown_files.len(), 1, "anchors is not an entity");
396    }
397
398    /// A recognised-but-structurally-invalid anchors member is a typed
399    /// failure — silent drop is exactly the publish-strip failure E3a
400    /// closes. (Unknown OTHER meta members keep tolerate-and-ignore.)
401    #[test]
402    fn rejects_malformed_anchors_member() {
403        let zip = build_archive(&[
404            (".memstead/config.json", ok_config()),
405            (".memstead/anchors.json", b"{ this is not valid json"),
406            ("foo.md", b"# Foo\n"),
407        ]);
408        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
409        assert!(
410            matches!(err, ValidationError::InvalidAnchorsMember { .. }),
411            "expected InvalidAnchorsMember, got {err:?}"
412        );
413    }
414
415    /// The optional `.memstead/provenance.json` payload is recognised and
416    /// surfaced verbatim on `ArchiveEntries` — not rejected as an unknown
417    /// file, not mistaken for a markdown entity (it lives under the meta
418    /// dir).
419    #[test]
420    fn recognises_provenance_member() {
421        let prov = br#"{"format":1,"history":"summarised","entities":{}}"#;
422        let zip = build_archive(&[
423            (".memstead/config.json", ok_config()),
424            (".memstead/provenance.json", prov),
425            ("foo.md", b"# Foo\n"),
426        ]);
427        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
428        assert_eq!(
429            entries.provenance_bytes.as_deref(),
430            Some(&prov[..]),
431            "provenance bytes surface verbatim"
432        );
433        assert_eq!(
434            entries.markdown_files.len(),
435            1,
436            "provenance is not an entity"
437        );
438    }
439
440    /// Forward-compat: an unrecognised member under the engine-owned
441    /// `.memstead/` meta dir (a payload a newer writer added) is tolerated
442    /// and ignored — the archive still extracts cleanly, so a future-meta-
443    /// bearing archive installs without error on an engine that does not
444    /// know the member. This is the additive contract the provenance
445    /// payload relies on. An unknown member OUTSIDE the meta dir is still
446    /// rejected.
447    #[test]
448    fn tolerates_unknown_meta_member_but_rejects_unknown_root_member() {
449        let tolerated = build_archive(&[
450            (".memstead/config.json", ok_config()),
451            (".memstead/future-payload.json", br#"{"x":1}"#),
452            ("foo.md", b"# Foo\n"),
453        ]);
454        let entries = extract_entries(&tolerated, &ValidatorLimits::DEFAULT)
455            .expect("unknown meta member must be tolerated");
456        assert_eq!(entries.markdown_files.len(), 1);
457        assert!(
458            entries.provenance_bytes.is_none(),
459            "an unrecognised meta member is ignored, not surfaced as provenance"
460        );
461
462        let rejected = build_archive(&[
463            (".memstead/config.json", ok_config()),
464            ("stray.txt", b"not allowed at root"),
465            ("foo.md", b"# Foo\n"),
466        ]);
467        let err = extract_entries(&rejected, &ValidatorLimits::DEFAULT).unwrap_err();
468        assert!(
469            matches!(err, ValidationError::UnknownFile(ref p) if p == "stray.txt"),
470            "unknown non-meta member must still be rejected, got {err:?}"
471        );
472    }
473
474    /// A meta member under a foreign (non-`.memstead/`) dir alongside a
475    /// current `.memstead/` config is rejected — it falls outside the
476    /// whitelist. Guards that only `.memstead/` is the tolerated layout.
477    #[test]
478    fn rejects_mixed_meta_dir_layout() {
479        let zip = build_archive(&[
480            (".memstead/config.json", ok_config()),
481            (".other/schema/schema.yaml", b"name: default\n"),
482            ("foo.md", b"# Foo\n"),
483        ]);
484        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
485        match err {
486            ValidationError::UnknownFile(path) => {
487                assert!(path.starts_with(".other/"), "path={path}");
488            }
489            other => panic!("expected UnknownFile for the foreign meta member, got {other:?}"),
490        }
491    }
492
493    /// `.md` files inside the meta dir are engine-internal, not
494    /// entities — they must not pass the whitelist as markdown.
495    #[test]
496    fn rejects_markdown_inside_meta_dir() {
497        let zip = build_archive(&[
498            (".memstead/config.json", ok_config()),
499            (".memstead/notes.md", b"# not an entity\n"),
500        ]);
501        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
502        assert!(
503            matches!(err, ValidationError::UnknownFile(_)),
504            "got {err:?}"
505        );
506    }
507
508    #[test]
509    fn markdown_files_are_sorted() {
510        let zip = build_archive(&[
511            (".memstead/config.json", ok_config()),
512            ("z.md", b"# Z\n"),
513            ("a.md", b"# A\n"),
514            ("m.md", b"# M\n"),
515        ]);
516        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
517        let paths: Vec<_> = entries
518            .markdown_files
519            .iter()
520            .map(|e| e.path.as_str())
521            .collect();
522        assert_eq!(paths, vec!["a.md", "m.md", "z.md"]);
523    }
524
525    #[test]
526    fn rejects_corrupt_zip() {
527        let err = extract_entries(b"not a zip at all", &ValidatorLimits::DEFAULT).unwrap_err();
528        assert!(matches!(err, ValidationError::Zip(_)));
529    }
530
531    #[test]
532    fn rejects_missing_config() {
533        let zip = build_archive(&[("foo.md", b"# Foo\n")]);
534        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
535        assert!(matches!(err, ValidationError::MissingConfig));
536    }
537
538    #[test]
539    fn rejects_non_markdown_non_config_file() {
540        let zip = build_archive(&[
541            (".memstead/config.json", ok_config()),
542            ("binary.exe", b"\x7fELF"),
543        ]);
544        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
545        match err {
546            ValidationError::UnknownFile(p) => assert_eq!(p, "binary.exe"),
547            other => panic!("expected UnknownFile, got {other:?}"),
548        }
549    }
550
551    #[test]
552    fn accepts_schema_package_entries() {
553        // The whitelist must admit the two shapes `export_mem` embeds:
554        // the manifest at `.memstead/schema/schema.yaml` and per-type YAMLs
555        // under `.memstead/schema/types/`. Anything else under
556        // `.memstead/schema/` is still an unknown file (asserted separately).
557        let zip = build_archive(&[
558            (".memstead/config.json", ok_config()),
559            ("foo.md", b"# Foo\n"),
560            (".memstead/schema/schema.yaml", b"name: default\n"),
561            (".memstead/schema/types/spec.yaml", b"name: spec\n"),
562        ]);
563        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
564        assert_eq!(entries.schema_files.len(), 2);
565        let paths: Vec<&str> = entries
566            .schema_files
567            .iter()
568            .map(|s| s.archive_path.as_str())
569            .collect();
570        assert_eq!(
571            paths,
572            vec![
573                ".memstead/schema/schema.yaml",
574                ".memstead/schema/types/spec.yaml"
575            ]
576        );
577    }
578
579    /// The sealed format marker rides the archive: it is admitted by
580    /// the whitelist and lands in `schema_files` so the loaders can
581    /// honor the package's metadata-polarity generation.
582    #[test]
583    fn accepts_schema_format_marker() {
584        let zip = build_archive(&[
585            (".memstead/config.json", ok_config()),
586            ("foo.md", b"# Foo\n"),
587            (".memstead/schema/schema.yaml", b"name: default\n"),
588            (
589                ".memstead/schema/schema-format.json",
590                b"{\"metadata_polarity\":\"required-opt-in\"}\n",
591            ),
592        ]);
593        let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
594        assert!(
595            entries
596                .schema_files
597                .iter()
598                .any(|s| s.archive_path == ".memstead/schema/schema-format.json"),
599            "marker must ride in schema_files"
600        );
601    }
602
603    #[test]
604    fn rejects_unknown_schema_subpath() {
605        // Anything under `.memstead/schema/` that isn't the manifest or a
606        // type file must still be rejected — otherwise archives could
607        // smuggle arbitrary payloads behind the whitelist.
608        let zip = build_archive(&[
609            (".memstead/config.json", ok_config()),
610            (".memstead/schema/unexpected.json", b"{}"),
611        ]);
612        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
613        assert!(matches!(err, ValidationError::UnknownFile(_)));
614    }
615
616    #[test]
617    fn rejects_nested_schema_type_file() {
618        let zip = build_archive(&[
619            (".memstead/config.json", ok_config()),
620            (
621                ".memstead/schema/types/nested/subtype.yaml",
622                b"name: subtype\n",
623            ),
624        ]);
625        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
626        assert!(matches!(err, ValidationError::UnknownFile(_)));
627    }
628
629    /// An unknown root-level file (not part of the whitelist) is
630    /// rejected as an unknown file — no special-casing survives.
631    #[test]
632    fn rejects_unknown_root_file() {
633        let zip = build_archive(&[
634            (".memstead/config.json", ok_config()),
635            (
636                "some-root.json",
637                br#"{"format":3,"name":"v","version":"0.1.0"}"#,
638            ),
639        ]);
640        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
641        assert!(
642            matches!(err, ValidationError::UnknownFile(ref p) if p == "some-root.json"),
643            "unknown root file must be rejected as UnknownFile, got {err:?}"
644        );
645    }
646
647    #[test]
648    fn rejects_zip_slip() {
649        let zip = build_archive(&[
650            (".memstead/config.json", ok_config()),
651            ("../escape.md", b"# Escape\n"),
652        ]);
653        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
654        assert!(matches!(err, ValidationError::Zip(_)));
655    }
656
657    #[test]
658    fn rejects_nested_zip_slip() {
659        let zip = build_archive(&[
660            (".memstead/config.json", ok_config()),
661            ("subdir/../../escape.md", b"# Escape\n"),
662        ]);
663        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
664        assert!(matches!(err, ValidationError::Zip(_)));
665    }
666
667    // Raw zip-bytes helpers below exercise rejection paths that the
668    // cooperative `ZipWriter` API normalizes away or refuses to emit:
669    // duplicate filenames, absolute paths, drive letters, symlinks.
670
671    /// Minimal CRC32/ISO-HDLC — zip expects this exact polynomial in
672    /// both the local file header and the central directory entry.
673    /// Inlined to avoid a dev-dependency just for the raw-zip helper.
674    fn crc32(data: &[u8]) -> u32 {
675        let mut crc = !0u32;
676        for &b in data {
677            crc ^= b as u32;
678            for _ in 0..8 {
679                let mask = (crc & 1).wrapping_neg();
680                crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
681            }
682        }
683        !crc
684    }
685
686    /// Hand-crafts a stored-only (uncompressed) zip with arbitrary
687    /// entry names — including shapes that `ZipWriter` rejects or
688    /// rewrites. Produces a structurally valid archive the `zip` crate
689    /// can open; validator-level checks are the ones doing the rejecting.
690    fn raw_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
691        let mut out: Vec<u8> = Vec::new();
692        let mut central: Vec<u8> = Vec::new();
693        let mut count: u16 = 0;
694
695        for (name, content) in entries {
696            let name_bytes = name.as_bytes();
697            let crc = crc32(content);
698            let size = content.len() as u32;
699            let offset = out.len() as u32;
700
701            // Local file header (PK\3\4)
702            out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
703            out.extend_from_slice(&10u16.to_le_bytes()); // version needed
704            out.extend_from_slice(&0u16.to_le_bytes()); // flags
705            out.extend_from_slice(&0u16.to_le_bytes()); // stored
706            out.extend_from_slice(&0u16.to_le_bytes()); // mtime
707            out.extend_from_slice(&0u16.to_le_bytes()); // mdate
708            out.extend_from_slice(&crc.to_le_bytes());
709            out.extend_from_slice(&size.to_le_bytes()); // compressed
710            out.extend_from_slice(&size.to_le_bytes()); // uncompressed
711            out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
712            out.extend_from_slice(&0u16.to_le_bytes()); // extra length
713            out.extend_from_slice(name_bytes);
714            out.extend_from_slice(content);
715
716            // Central directory file header (PK\1\2)
717            central.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
718            central.extend_from_slice(&20u16.to_le_bytes()); // version made by
719            central.extend_from_slice(&10u16.to_le_bytes()); // version needed
720            central.extend_from_slice(&0u16.to_le_bytes()); // flags
721            central.extend_from_slice(&0u16.to_le_bytes()); // stored
722            central.extend_from_slice(&0u16.to_le_bytes()); // mtime
723            central.extend_from_slice(&0u16.to_le_bytes()); // mdate
724            central.extend_from_slice(&crc.to_le_bytes());
725            central.extend_from_slice(&size.to_le_bytes());
726            central.extend_from_slice(&size.to_le_bytes());
727            central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
728            central.extend_from_slice(&0u16.to_le_bytes()); // extra
729            central.extend_from_slice(&0u16.to_le_bytes()); // comment
730            central.extend_from_slice(&0u16.to_le_bytes()); // disk number
731            central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
732            central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
733            central.extend_from_slice(&offset.to_le_bytes()); // LFH offset
734            central.extend_from_slice(name_bytes);
735
736            count += 1;
737        }
738
739        let cd_offset = out.len() as u32;
740        let cd_size = central.len() as u32;
741        out.extend_from_slice(&central);
742
743        // End of central directory (PK\5\6)
744        out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
745        out.extend_from_slice(&0u16.to_le_bytes()); // disk number
746        out.extend_from_slice(&0u16.to_le_bytes()); // disk with CD start
747        out.extend_from_slice(&count.to_le_bytes()); // entries this disk
748        out.extend_from_slice(&count.to_le_bytes()); // entries total
749        out.extend_from_slice(&cd_size.to_le_bytes());
750        out.extend_from_slice(&cd_offset.to_le_bytes());
751        out.extend_from_slice(&0u16.to_le_bytes()); // comment length
752
753        out
754    }
755
756    // Duplicate-entry path: verified empirically with `raw_zip` that
757    // the zip 8 reader itself collapses duplicates during central-
758    // directory parse (only the last entry survives, `archive.len()`
759    // returns 1). The `seen_paths` guard in `extract_entries` is
760    // therefore only reachable if a future zip-crate release preserves
761    // duplicates — kept as defense-in-depth, cannot be exercised in a
762    // unit test against current zip 8.x.
763
764    #[test]
765    fn rejects_absolute_path_entry() {
766        let zip = raw_zip(&[
767            (".memstead/config.json", ok_config()),
768            ("/etc/passwd.md", b"# Escape\n"),
769        ]);
770        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
771        // `enclosed_name()` refuses absolute paths → validator maps to
772        // `Zip` with the unsafe-entry reason.
773        match err {
774            ValidationError::Zip(reason) => {
775                assert!(
776                    reason.contains("unsafe entry path"),
777                    "unexpected reason: {reason}"
778                );
779            }
780            other => panic!("expected Zip(unsafe entry), got {other:?}"),
781        }
782    }
783
784    #[test]
785    fn rejects_windows_drive_letter_entry() {
786        // Backslashes are allowed inside zip member names on Windows-
787        // produced archives; `enclosed_name()` normalizes separators
788        // and then refuses drive-prefixed absolute paths.
789        let zip = raw_zip(&[
790            (".memstead/config.json", ok_config()),
791            ("C:\\evil.md", b"# Escape\n"),
792        ]);
793        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
794        assert!(matches!(err, ValidationError::Zip(_)));
795    }
796
797    #[test]
798    fn rejects_symlink_entry() {
799        // The cooperative writer exposes `add_symlink`, which is the
800        // only path that sets the external-attributes bit the reader
801        // interprets as `is_symlink() == true`. Defense-in-depth test
802        // for R5: even if an adversary smuggles a symlink past the
803        // whitelist, the structural check fires.
804        let mut buf: Vec<u8> = Vec::new();
805        {
806            let cursor = Cursor::new(&mut buf);
807            let mut w = zip::ZipWriter::new(cursor);
808            w.start_file(".memstead/config.json", SimpleFileOptions::default())
809                .unwrap();
810            w.write_all(ok_config()).unwrap();
811            w.add_symlink("link.md", "target.md", SimpleFileOptions::default())
812                .unwrap();
813            w.finish().unwrap();
814        }
815        let err = extract_entries(&buf, &ValidatorLimits::DEFAULT).unwrap_err();
816        match err {
817            ValidationError::Symlink(name) => assert_eq!(name, "link.md"),
818            other => panic!("expected Symlink, got {other:?}"),
819        }
820    }
821
822    #[test]
823    fn rejects_compressed_archive_too_large() {
824        let mut limits = ValidatorLimits::DEFAULT;
825        limits.max_compressed_archive = 64;
826        let zip = build_archive(&[
827            (".memstead/config.json", ok_config()),
828            ("foo.md", b"# Foo\n"),
829        ]);
830        let err = extract_entries(&zip, &limits).unwrap_err();
831        assert!(matches!(
832            err,
833            ValidationError::SizeCapExceeded {
834                kind: SizeCapKind::CompressedArchive,
835                ..
836            }
837        ));
838    }
839
840    #[test]
841    fn rejects_single_entry_too_large() {
842        let mut limits = ValidatorLimits::DEFAULT;
843        limits.max_uncompressed_entry = 10;
844        let zip = build_archive(&[
845            (".memstead/config.json", ok_config()),
846            ("big.md", &[b'x'; 100]),
847        ]);
848        let err = extract_entries(&zip, &limits).unwrap_err();
849        assert!(matches!(
850            err,
851            ValidationError::SizeCapExceeded {
852                kind: SizeCapKind::UncompressedEntry,
853                ..
854            }
855        ));
856    }
857
858    #[test]
859    fn rejects_config_file_too_large() {
860        let mut limits = ValidatorLimits::DEFAULT;
861        limits.max_config_file = 10;
862        let zip = build_archive(&[
863            (".memstead/config.json", &[b'x'; 50]),
864            ("foo.md", b"# Foo\n"),
865        ]);
866        let err = extract_entries(&zip, &limits).unwrap_err();
867        assert!(matches!(
868            err,
869            ValidationError::SizeCapExceeded {
870                kind: SizeCapKind::ConfigFile,
871                ..
872            }
873        ));
874    }
875
876    #[test]
877    fn rejects_uncompressed_sum_too_large() {
878        let mut limits = ValidatorLimits::DEFAULT;
879        limits.max_uncompressed_archive = 30;
880        let zip = build_archive(&[
881            (".memstead/config.json", ok_config()),
882            ("a.md", &[b'x'; 20]),
883            ("b.md", &[b'x'; 20]),
884        ]);
885        let err = extract_entries(&zip, &limits).unwrap_err();
886        assert!(matches!(
887            err,
888            ValidationError::SizeCapExceeded {
889                kind: SizeCapKind::UncompressedArchive,
890                ..
891            }
892        ));
893    }
894
895    #[test]
896    fn rejects_entry_count_too_large() {
897        let mut limits = ValidatorLimits::DEFAULT;
898        limits.max_file_count = 2;
899        let zip = build_archive(&[
900            (".memstead/config.json", ok_config()),
901            ("a.md", b"# A\n"),
902            ("b.md", b"# B\n"),
903        ]);
904        let err = extract_entries(&zip, &limits).unwrap_err();
905        assert!(matches!(
906            err,
907            ValidationError::SizeCapExceeded {
908                kind: SizeCapKind::EntryCount,
909                ..
910            }
911        ));
912    }
913
914    #[test]
915    fn rejects_path_too_long() {
916        let mut limits = ValidatorLimits::DEFAULT;
917        limits.max_path_length = 10;
918        let long_name = format!("{}.md", "a".repeat(20));
919        let zip = build_archive(&[
920            (".memstead/config.json", ok_config()),
921            (long_name.as_str(), b"# x\n"),
922        ]);
923        let err = extract_entries(&zip, &limits).unwrap_err();
924        assert!(matches!(err, ValidationError::PathTooLong { .. }));
925    }
926
927    #[test]
928    fn rejects_path_too_deep() {
929        let mut limits = ValidatorLimits::DEFAULT;
930        limits.max_path_depth = 2;
931        let zip = build_archive(&[
932            (".memstead/config.json", ok_config()),
933            ("a/b/c/d.md", b"# x\n"),
934        ]);
935        let err = extract_entries(&zip, &limits).unwrap_err();
936        assert!(matches!(err, ValidationError::PathTooDeep { .. }));
937    }
938
939    #[test]
940    fn rejects_non_utf8_markdown_content() {
941        let zip = build_archive(&[
942            (".memstead/config.json", ok_config()),
943            ("bad.md", &[0xff, 0xfe, 0xff]),
944        ]);
945        let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
946        assert!(matches!(err, ValidationError::Utf8 { .. }));
947    }
948}