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 let Some(rest) = rest.strip_prefix("types/") else {
322 return false;
323 };
324 if !rest.ends_with(".yaml") {
325 return false;
326 }
327 let stem = &rest[..rest.len() - ".yaml".len()];
328 !stem.is_empty() && !stem.contains('/')
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use std::io::Write;
335 use zip::write::SimpleFileOptions;
336
337 fn build_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
340 let mut buf = Vec::new();
341 {
342 let cursor = Cursor::new(&mut buf);
343 let mut w = zip::ZipWriter::new(cursor);
344 let options =
345 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
346 for (name, content) in entries {
347 w.start_file(*name, options).unwrap();
348 w.write_all(content).unwrap();
349 }
350 w.finish().unwrap();
351 }
352 buf
353 }
354
355 fn ok_config() -> &'static [u8] {
356 br#"{"format":3,"name":"v","version":"0.1.0","schema":"default@1.0.0"}"#
357 }
358
359 #[test]
360 fn accepts_minimal_valid_archive() {
361 let zip = build_archive(&[
362 (".memstead/config.json", ok_config()),
363 ("foo.md", b"# Foo\n"),
364 ]);
365 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
366 assert_eq!(entries.markdown_files.len(), 1);
367 assert_eq!(entries.markdown_files[0].path, "foo.md");
368 assert!(entries.provenance_bytes.is_none());
371 }
372
373 #[test]
377 fn recognises_valid_anchors_member() {
378 let anchors = br#"{"version":1,"entities":{"v--foo":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
379 let zip = build_archive(&[
380 (".memstead/config.json", ok_config()),
381 (".memstead/anchors.json", anchors),
382 ("foo.md", b"# Foo\n"),
383 ]);
384 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
385 assert_eq!(
386 entries.anchors_bytes.as_deref(),
387 Some(&anchors[..]),
388 "anchors bytes surface verbatim"
389 );
390 assert_eq!(entries.markdown_files.len(), 1, "anchors is not an entity");
391 }
392
393 #[test]
397 fn rejects_malformed_anchors_member() {
398 let zip = build_archive(&[
399 (".memstead/config.json", ok_config()),
400 (".memstead/anchors.json", b"{ this is not valid json"),
401 ("foo.md", b"# Foo\n"),
402 ]);
403 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
404 assert!(
405 matches!(err, ValidationError::InvalidAnchorsMember { .. }),
406 "expected InvalidAnchorsMember, got {err:?}"
407 );
408 }
409
410 #[test]
415 fn recognises_provenance_member() {
416 let prov = br#"{"format":1,"history":"summarised","entities":{}}"#;
417 let zip = build_archive(&[
418 (".memstead/config.json", ok_config()),
419 (".memstead/provenance.json", prov),
420 ("foo.md", b"# Foo\n"),
421 ]);
422 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
423 assert_eq!(
424 entries.provenance_bytes.as_deref(),
425 Some(&prov[..]),
426 "provenance bytes surface verbatim"
427 );
428 assert_eq!(
429 entries.markdown_files.len(),
430 1,
431 "provenance is not an entity"
432 );
433 }
434
435 #[test]
443 fn tolerates_unknown_meta_member_but_rejects_unknown_root_member() {
444 let tolerated = build_archive(&[
445 (".memstead/config.json", ok_config()),
446 (".memstead/future-payload.json", br#"{"x":1}"#),
447 ("foo.md", b"# Foo\n"),
448 ]);
449 let entries = extract_entries(&tolerated, &ValidatorLimits::DEFAULT)
450 .expect("unknown meta member must be tolerated");
451 assert_eq!(entries.markdown_files.len(), 1);
452 assert!(
453 entries.provenance_bytes.is_none(),
454 "an unrecognised meta member is ignored, not surfaced as provenance"
455 );
456
457 let rejected = build_archive(&[
458 (".memstead/config.json", ok_config()),
459 ("stray.txt", b"not allowed at root"),
460 ("foo.md", b"# Foo\n"),
461 ]);
462 let err = extract_entries(&rejected, &ValidatorLimits::DEFAULT).unwrap_err();
463 assert!(
464 matches!(err, ValidationError::UnknownFile(ref p) if p == "stray.txt"),
465 "unknown non-meta member must still be rejected, got {err:?}"
466 );
467 }
468
469 #[test]
473 fn rejects_mixed_meta_dir_layout() {
474 let zip = build_archive(&[
475 (".memstead/config.json", ok_config()),
476 (".other/schema/schema.yaml", b"name: default\n"),
477 ("foo.md", b"# Foo\n"),
478 ]);
479 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
480 match err {
481 ValidationError::UnknownFile(path) => {
482 assert!(path.starts_with(".other/"), "path={path}");
483 }
484 other => panic!("expected UnknownFile for the foreign meta member, got {other:?}"),
485 }
486 }
487
488 #[test]
491 fn rejects_markdown_inside_meta_dir() {
492 let zip = build_archive(&[
493 (".memstead/config.json", ok_config()),
494 (".memstead/notes.md", b"# not an entity\n"),
495 ]);
496 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
497 assert!(
498 matches!(err, ValidationError::UnknownFile(_)),
499 "got {err:?}"
500 );
501 }
502
503 #[test]
504 fn markdown_files_are_sorted() {
505 let zip = build_archive(&[
506 (".memstead/config.json", ok_config()),
507 ("z.md", b"# Z\n"),
508 ("a.md", b"# A\n"),
509 ("m.md", b"# M\n"),
510 ]);
511 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
512 let paths: Vec<_> = entries
513 .markdown_files
514 .iter()
515 .map(|e| e.path.as_str())
516 .collect();
517 assert_eq!(paths, vec!["a.md", "m.md", "z.md"]);
518 }
519
520 #[test]
521 fn rejects_corrupt_zip() {
522 let err = extract_entries(b"not a zip at all", &ValidatorLimits::DEFAULT).unwrap_err();
523 assert!(matches!(err, ValidationError::Zip(_)));
524 }
525
526 #[test]
527 fn rejects_missing_config() {
528 let zip = build_archive(&[("foo.md", b"# Foo\n")]);
529 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
530 assert!(matches!(err, ValidationError::MissingConfig));
531 }
532
533 #[test]
534 fn rejects_non_markdown_non_config_file() {
535 let zip = build_archive(&[
536 (".memstead/config.json", ok_config()),
537 ("binary.exe", b"\x7fELF"),
538 ]);
539 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
540 match err {
541 ValidationError::UnknownFile(p) => assert_eq!(p, "binary.exe"),
542 other => panic!("expected UnknownFile, got {other:?}"),
543 }
544 }
545
546 #[test]
547 fn accepts_schema_package_entries() {
548 let zip = build_archive(&[
553 (".memstead/config.json", ok_config()),
554 ("foo.md", b"# Foo\n"),
555 (".memstead/schema/schema.yaml", b"name: default\n"),
556 (".memstead/schema/types/spec.yaml", b"name: spec\n"),
557 ]);
558 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
559 assert_eq!(entries.schema_files.len(), 2);
560 let paths: Vec<&str> = entries
561 .schema_files
562 .iter()
563 .map(|s| s.archive_path.as_str())
564 .collect();
565 assert_eq!(
566 paths,
567 vec![
568 ".memstead/schema/schema.yaml",
569 ".memstead/schema/types/spec.yaml"
570 ]
571 );
572 }
573
574 #[test]
575 fn rejects_unknown_schema_subpath() {
576 let zip = build_archive(&[
580 (".memstead/config.json", ok_config()),
581 (".memstead/schema/unexpected.json", b"{}"),
582 ]);
583 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
584 assert!(matches!(err, ValidationError::UnknownFile(_)));
585 }
586
587 #[test]
588 fn rejects_nested_schema_type_file() {
589 let zip = build_archive(&[
590 (".memstead/config.json", ok_config()),
591 (
592 ".memstead/schema/types/nested/subtype.yaml",
593 b"name: subtype\n",
594 ),
595 ]);
596 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
597 assert!(matches!(err, ValidationError::UnknownFile(_)));
598 }
599
600 #[test]
603 fn rejects_unknown_root_file() {
604 let zip = build_archive(&[
605 (".memstead/config.json", ok_config()),
606 (
607 "some-root.json",
608 br#"{"format":3,"name":"v","version":"0.1.0"}"#,
609 ),
610 ]);
611 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
612 assert!(
613 matches!(err, ValidationError::UnknownFile(ref p) if p == "some-root.json"),
614 "unknown root file must be rejected as UnknownFile, got {err:?}"
615 );
616 }
617
618 #[test]
619 fn rejects_zip_slip() {
620 let zip = build_archive(&[
621 (".memstead/config.json", ok_config()),
622 ("../escape.md", b"# Escape\n"),
623 ]);
624 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
625 assert!(matches!(err, ValidationError::Zip(_)));
626 }
627
628 #[test]
629 fn rejects_nested_zip_slip() {
630 let zip = build_archive(&[
631 (".memstead/config.json", ok_config()),
632 ("subdir/../../escape.md", b"# Escape\n"),
633 ]);
634 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
635 assert!(matches!(err, ValidationError::Zip(_)));
636 }
637
638 fn crc32(data: &[u8]) -> u32 {
646 let mut crc = !0u32;
647 for &b in data {
648 crc ^= b as u32;
649 for _ in 0..8 {
650 let mask = (crc & 1).wrapping_neg();
651 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
652 }
653 }
654 !crc
655 }
656
657 fn raw_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
662 let mut out: Vec<u8> = Vec::new();
663 let mut central: Vec<u8> = Vec::new();
664 let mut count: u16 = 0;
665
666 for (name, content) in entries {
667 let name_bytes = name.as_bytes();
668 let crc = crc32(content);
669 let size = content.len() as u32;
670 let offset = out.len() as u32;
671
672 out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
674 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());
680 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());
683 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name_bytes);
685 out.extend_from_slice(content);
686
687 central.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
689 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());
696 central.extend_from_slice(&size.to_le_bytes());
697 central.extend_from_slice(&size.to_le_bytes());
698 central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
699 central.extend_from_slice(&0u16.to_le_bytes()); 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);
706
707 count += 1;
708 }
709
710 let cd_offset = out.len() as u32;
711 let cd_size = central.len() as u32;
712 out.extend_from_slice(¢ral);
713
714 out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
716 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());
721 out.extend_from_slice(&cd_offset.to_le_bytes());
722 out.extend_from_slice(&0u16.to_le_bytes()); out
725 }
726
727 #[test]
736 fn rejects_absolute_path_entry() {
737 let zip = raw_zip(&[
738 (".memstead/config.json", ok_config()),
739 ("/etc/passwd.md", b"# Escape\n"),
740 ]);
741 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
742 match err {
745 ValidationError::Zip(reason) => {
746 assert!(
747 reason.contains("unsafe entry path"),
748 "unexpected reason: {reason}"
749 );
750 }
751 other => panic!("expected Zip(unsafe entry), got {other:?}"),
752 }
753 }
754
755 #[test]
756 fn rejects_windows_drive_letter_entry() {
757 let zip = raw_zip(&[
761 (".memstead/config.json", ok_config()),
762 ("C:\\evil.md", b"# Escape\n"),
763 ]);
764 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
765 assert!(matches!(err, ValidationError::Zip(_)));
766 }
767
768 #[test]
769 fn rejects_symlink_entry() {
770 let mut buf: Vec<u8> = Vec::new();
776 {
777 let cursor = Cursor::new(&mut buf);
778 let mut w = zip::ZipWriter::new(cursor);
779 w.start_file(".memstead/config.json", SimpleFileOptions::default())
780 .unwrap();
781 w.write_all(ok_config()).unwrap();
782 w.add_symlink("link.md", "target.md", SimpleFileOptions::default())
783 .unwrap();
784 w.finish().unwrap();
785 }
786 let err = extract_entries(&buf, &ValidatorLimits::DEFAULT).unwrap_err();
787 match err {
788 ValidationError::Symlink(name) => assert_eq!(name, "link.md"),
789 other => panic!("expected Symlink, got {other:?}"),
790 }
791 }
792
793 #[test]
794 fn rejects_compressed_archive_too_large() {
795 let mut limits = ValidatorLimits::DEFAULT;
796 limits.max_compressed_archive = 64;
797 let zip = build_archive(&[
798 (".memstead/config.json", ok_config()),
799 ("foo.md", b"# Foo\n"),
800 ]);
801 let err = extract_entries(&zip, &limits).unwrap_err();
802 assert!(matches!(
803 err,
804 ValidationError::SizeCapExceeded {
805 kind: SizeCapKind::CompressedArchive,
806 ..
807 }
808 ));
809 }
810
811 #[test]
812 fn rejects_single_entry_too_large() {
813 let mut limits = ValidatorLimits::DEFAULT;
814 limits.max_uncompressed_entry = 10;
815 let zip = build_archive(&[
816 (".memstead/config.json", ok_config()),
817 ("big.md", &[b'x'; 100]),
818 ]);
819 let err = extract_entries(&zip, &limits).unwrap_err();
820 assert!(matches!(
821 err,
822 ValidationError::SizeCapExceeded {
823 kind: SizeCapKind::UncompressedEntry,
824 ..
825 }
826 ));
827 }
828
829 #[test]
830 fn rejects_config_file_too_large() {
831 let mut limits = ValidatorLimits::DEFAULT;
832 limits.max_config_file = 10;
833 let zip = build_archive(&[
834 (".memstead/config.json", &[b'x'; 50]),
835 ("foo.md", b"# Foo\n"),
836 ]);
837 let err = extract_entries(&zip, &limits).unwrap_err();
838 assert!(matches!(
839 err,
840 ValidationError::SizeCapExceeded {
841 kind: SizeCapKind::ConfigFile,
842 ..
843 }
844 ));
845 }
846
847 #[test]
848 fn rejects_uncompressed_sum_too_large() {
849 let mut limits = ValidatorLimits::DEFAULT;
850 limits.max_uncompressed_archive = 30;
851 let zip = build_archive(&[
852 (".memstead/config.json", ok_config()),
853 ("a.md", &[b'x'; 20]),
854 ("b.md", &[b'x'; 20]),
855 ]);
856 let err = extract_entries(&zip, &limits).unwrap_err();
857 assert!(matches!(
858 err,
859 ValidationError::SizeCapExceeded {
860 kind: SizeCapKind::UncompressedArchive,
861 ..
862 }
863 ));
864 }
865
866 #[test]
867 fn rejects_entry_count_too_large() {
868 let mut limits = ValidatorLimits::DEFAULT;
869 limits.max_file_count = 2;
870 let zip = build_archive(&[
871 (".memstead/config.json", ok_config()),
872 ("a.md", b"# A\n"),
873 ("b.md", b"# B\n"),
874 ]);
875 let err = extract_entries(&zip, &limits).unwrap_err();
876 assert!(matches!(
877 err,
878 ValidationError::SizeCapExceeded {
879 kind: SizeCapKind::EntryCount,
880 ..
881 }
882 ));
883 }
884
885 #[test]
886 fn rejects_path_too_long() {
887 let mut limits = ValidatorLimits::DEFAULT;
888 limits.max_path_length = 10;
889 let long_name = format!("{}.md", "a".repeat(20));
890 let zip = build_archive(&[
891 (".memstead/config.json", ok_config()),
892 (long_name.as_str(), b"# x\n"),
893 ]);
894 let err = extract_entries(&zip, &limits).unwrap_err();
895 assert!(matches!(err, ValidationError::PathTooLong { .. }));
896 }
897
898 #[test]
899 fn rejects_path_too_deep() {
900 let mut limits = ValidatorLimits::DEFAULT;
901 limits.max_path_depth = 2;
902 let zip = build_archive(&[
903 (".memstead/config.json", ok_config()),
904 ("a/b/c/d.md", b"# x\n"),
905 ]);
906 let err = extract_entries(&zip, &limits).unwrap_err();
907 assert!(matches!(err, ValidationError::PathTooDeep { .. }));
908 }
909
910 #[test]
911 fn rejects_non_utf8_markdown_content() {
912 let zip = build_archive(&[
913 (".memstead/config.json", ok_config()),
914 ("bad.md", &[0xff, 0xfe, 0xff]),
915 ]);
916 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
917 assert!(matches!(err, ValidationError::Utf8 { .. }));
918 }
919}