Skip to main content

memstead_base/validator/
archive.rs

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