1use 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#[derive(Debug)]
22pub struct MarkdownEntry {
23 pub path: String,
24 pub content: String,
25}
26
27#[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 pub provenance_bytes: Option<Vec<u8>>,
53 pub anchors_bytes: Option<Vec<u8>>,
61}
62
63pub 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 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 let is_markdown =
167 path_string.ends_with(".md") && !path_string.starts_with(&meta_dir_prefix);
168 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 if is_ignored_meta {
240 continue;
241 }
242
243 if is_config {
244 config_bytes = Some(buf);
245 } else if is_provenance {
246 provenance_bytes = Some(buf);
251 } else if is_anchors {
252 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 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
308fn 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 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 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 assert!(entries.provenance_bytes.is_none());
376 }
377
378 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 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 out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
703 out.extend_from_slice(&10u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc.to_le_bytes());
709 out.extend_from_slice(&size.to_le_bytes()); out.extend_from_slice(&size.to_le_bytes()); out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
712 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name_bytes);
714 out.extend_from_slice(content);
715
716 central.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
718 central.extend_from_slice(&20u16.to_le_bytes()); central.extend_from_slice(&10u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); 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()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u32.to_le_bytes()); central.extend_from_slice(&offset.to_le_bytes()); 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(¢ral);
742
743 out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
745 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&count.to_le_bytes()); out.extend_from_slice(&count.to_le_bytes()); 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()); out
754 }
755
756 #[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 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 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 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}