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