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
42pub 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 pub provenance_bytes: Option<Vec<u8>>,
74 pub anchors_bytes: Option<Vec<u8>>,
82}
83
84pub 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 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 let is_markdown =
188 path_string.ends_with(".md") && !path_string.starts_with(&meta_dir_prefix);
189 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 if is_ignored_meta {
261 continue;
262 }
263
264 if is_config {
265 config_bytes = Some(buf);
266 } else if is_provenance {
267 provenance_bytes = Some(buf);
272 } else if is_anchors {
273 crate::anchor::AnchorSidecar::from_bytes(&buf).map_err(|e| {
280 ValidationError::InvalidAnchorsMember {
281 reason: e.to_string(),
282 }
283 })?;
284 anchors_bytes = Some(buf);
285 } else {
286 let content = match std::str::from_utf8(&buf) {
287 Ok(s) => s.to_string(),
288 Err(e) => {
289 return Err(ValidationError::Utf8 {
290 path: path_string,
291 offset: e.valid_up_to(),
292 });
293 }
294 };
295 let content = content.replace("\r\n", "\n");
301 if is_schema {
302 schema_files.push(SchemaFile {
303 archive_path: path_string,
304 content,
305 });
306 } else {
307 markdown_files.push(MarkdownEntry {
308 path: path_string,
309 content,
310 });
311 }
312 }
313 }
314
315 let config_bytes = config_bytes.ok_or(ValidationError::MissingConfig)?;
316
317 markdown_files.sort_by(|a, b| a.path.cmp(&b.path));
318 schema_files.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
319
320 Ok(ArchiveEntries {
321 config_bytes,
322 markdown_files,
323 schema_files,
324 provenance_bytes,
325 anchors_bytes,
326 })
327}
328
329fn is_schema_path(path: &str) -> bool {
336 let Some(rest) = path.strip_prefix(ARCHIVE_SCHEMA_PREFIX) else {
337 return false;
338 };
339 if rest == "schema.yaml" {
340 return true;
341 }
342 if rest == memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE {
345 return true;
346 }
347 let Some(rest) = rest.strip_prefix("types/") else {
348 return false;
349 };
350 if !rest.ends_with(".yaml") {
351 return false;
352 }
353 let stem = &rest[..rest.len() - ".yaml".len()];
354 !stem.is_empty() && !stem.contains('/')
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use std::io::Write;
361 use zip::write::SimpleFileOptions;
362
363 fn build_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
366 let mut buf = Vec::new();
367 {
368 let cursor = Cursor::new(&mut buf);
369 let mut w = zip::ZipWriter::new(cursor);
370 let options =
371 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
372 for (name, content) in entries {
373 w.start_file(*name, options).unwrap();
374 w.write_all(content).unwrap();
375 }
376 w.finish().unwrap();
377 }
378 buf
379 }
380
381 fn ok_config() -> &'static [u8] {
382 br#"{"format":3,"name":"v","version":"0.1.0","schema":"default@1.0.0"}"#
383 }
384
385 #[test]
386 fn accepts_minimal_valid_archive() {
387 let zip = build_archive(&[
388 (".memstead/config.json", ok_config()),
389 ("foo.md", b"# Foo\n"),
390 ]);
391 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
392 assert_eq!(entries.markdown_files.len(), 1);
393 assert_eq!(entries.markdown_files[0].path, "foo.md");
394 assert!(entries.provenance_bytes.is_none());
397 }
398
399 #[test]
403 fn recognises_valid_anchors_member() {
404 let anchors = br#"{"version":1,"entities":{"v--foo":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
405 let zip = build_archive(&[
406 (".memstead/config.json", ok_config()),
407 (".memstead/anchors.json", anchors),
408 ("foo.md", b"# Foo\n"),
409 ]);
410 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
411 assert_eq!(
412 entries.anchors_bytes.as_deref(),
413 Some(&anchors[..]),
414 "anchors bytes surface verbatim"
415 );
416 assert_eq!(entries.markdown_files.len(), 1, "anchors is not an entity");
417 }
418
419 #[test]
423 fn rejects_malformed_anchors_member() {
424 let zip = build_archive(&[
425 (".memstead/config.json", ok_config()),
426 (".memstead/anchors.json", b"{ this is not valid json"),
427 ("foo.md", b"# Foo\n"),
428 ]);
429 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
430 assert!(
431 matches!(err, ValidationError::InvalidAnchorsMember { .. }),
432 "expected InvalidAnchorsMember, got {err:?}"
433 );
434 }
435
436 #[test]
441 fn recognises_provenance_member() {
442 let prov = br#"{"format":1,"history":"summarised","entities":{}}"#;
443 let zip = build_archive(&[
444 (".memstead/config.json", ok_config()),
445 (".memstead/provenance.json", prov),
446 ("foo.md", b"# Foo\n"),
447 ]);
448 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
449 assert_eq!(
450 entries.provenance_bytes.as_deref(),
451 Some(&prov[..]),
452 "provenance bytes surface verbatim"
453 );
454 assert_eq!(
455 entries.markdown_files.len(),
456 1,
457 "provenance is not an entity"
458 );
459 }
460
461 #[test]
469 fn tolerates_unknown_meta_member_but_rejects_unknown_root_member() {
470 let tolerated = build_archive(&[
471 (".memstead/config.json", ok_config()),
472 (".memstead/future-payload.json", br#"{"x":1}"#),
473 ("foo.md", b"# Foo\n"),
474 ]);
475 let entries = extract_entries(&tolerated, &ValidatorLimits::DEFAULT)
476 .expect("unknown meta member must be tolerated");
477 assert_eq!(entries.markdown_files.len(), 1);
478 assert!(
479 entries.provenance_bytes.is_none(),
480 "an unrecognised meta member is ignored, not surfaced as provenance"
481 );
482
483 let rejected = build_archive(&[
484 (".memstead/config.json", ok_config()),
485 ("stray.txt", b"not allowed at root"),
486 ("foo.md", b"# Foo\n"),
487 ]);
488 let err = extract_entries(&rejected, &ValidatorLimits::DEFAULT).unwrap_err();
489 assert!(
490 matches!(err, ValidationError::UnknownFile(ref p) if p == "stray.txt"),
491 "unknown non-meta member must still be rejected, got {err:?}"
492 );
493 }
494
495 #[test]
499 fn rejects_mixed_meta_dir_layout() {
500 let zip = build_archive(&[
501 (".memstead/config.json", ok_config()),
502 (".other/schema/schema.yaml", b"name: default\n"),
503 ("foo.md", b"# Foo\n"),
504 ]);
505 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
506 match err {
507 ValidationError::UnknownFile(path) => {
508 assert!(path.starts_with(".other/"), "path={path}");
509 }
510 other => panic!("expected UnknownFile for the foreign meta member, got {other:?}"),
511 }
512 }
513
514 #[test]
517 fn rejects_markdown_inside_meta_dir() {
518 let zip = build_archive(&[
519 (".memstead/config.json", ok_config()),
520 (".memstead/notes.md", b"# not an entity\n"),
521 ]);
522 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
523 assert!(
524 matches!(err, ValidationError::UnknownFile(_)),
525 "got {err:?}"
526 );
527 }
528
529 #[test]
530 fn markdown_files_are_sorted() {
531 let zip = build_archive(&[
532 (".memstead/config.json", ok_config()),
533 ("z.md", b"# Z\n"),
534 ("a.md", b"# A\n"),
535 ("m.md", b"# M\n"),
536 ]);
537 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
538 let paths: Vec<_> = entries
539 .markdown_files
540 .iter()
541 .map(|e| e.path.as_str())
542 .collect();
543 assert_eq!(paths, vec!["a.md", "m.md", "z.md"]);
544 }
545
546 #[test]
547 fn rejects_corrupt_zip() {
548 let err = extract_entries(b"not a zip at all", &ValidatorLimits::DEFAULT).unwrap_err();
549 assert!(matches!(err, ValidationError::Zip(_)));
550 }
551
552 #[test]
553 fn rejects_missing_config() {
554 let zip = build_archive(&[("foo.md", b"# Foo\n")]);
555 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
556 assert!(matches!(err, ValidationError::MissingConfig));
557 }
558
559 #[test]
560 fn rejects_non_markdown_non_config_file() {
561 let zip = build_archive(&[
562 (".memstead/config.json", ok_config()),
563 ("binary.exe", b"\x7fELF"),
564 ]);
565 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
566 match err {
567 ValidationError::UnknownFile(p) => assert_eq!(p, "binary.exe"),
568 other => panic!("expected UnknownFile, got {other:?}"),
569 }
570 }
571
572 #[test]
573 fn accepts_schema_package_entries() {
574 let zip = build_archive(&[
579 (".memstead/config.json", ok_config()),
580 ("foo.md", b"# Foo\n"),
581 (".memstead/schema/schema.yaml", b"name: default\n"),
582 (".memstead/schema/types/spec.yaml", b"name: spec\n"),
583 ]);
584 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
585 assert_eq!(entries.schema_files.len(), 2);
586 let paths: Vec<&str> = entries
587 .schema_files
588 .iter()
589 .map(|s| s.archive_path.as_str())
590 .collect();
591 assert_eq!(
592 paths,
593 vec![
594 ".memstead/schema/schema.yaml",
595 ".memstead/schema/types/spec.yaml"
596 ]
597 );
598 }
599
600 #[test]
604 fn accepts_schema_format_marker() {
605 let zip = build_archive(&[
606 (".memstead/config.json", ok_config()),
607 ("foo.md", b"# Foo\n"),
608 (".memstead/schema/schema.yaml", b"name: default\n"),
609 (
610 ".memstead/schema/schema-format.json",
611 b"{\"metadata_polarity\":\"required-opt-in\"}\n",
612 ),
613 ]);
614 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
615 assert!(
616 entries
617 .schema_files
618 .iter()
619 .any(|s| s.archive_path == ".memstead/schema/schema-format.json"),
620 "marker must ride in schema_files"
621 );
622 }
623
624 #[test]
625 fn rejects_unknown_schema_subpath() {
626 let zip = build_archive(&[
630 (".memstead/config.json", ok_config()),
631 (".memstead/schema/unexpected.json", b"{}"),
632 ]);
633 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
634 assert!(matches!(err, ValidationError::UnknownFile(_)));
635 }
636
637 #[test]
638 fn rejects_nested_schema_type_file() {
639 let zip = build_archive(&[
640 (".memstead/config.json", ok_config()),
641 (
642 ".memstead/schema/types/nested/subtype.yaml",
643 b"name: subtype\n",
644 ),
645 ]);
646 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
647 assert!(matches!(err, ValidationError::UnknownFile(_)));
648 }
649
650 #[test]
653 fn rejects_unknown_root_file() {
654 let zip = build_archive(&[
655 (".memstead/config.json", ok_config()),
656 (
657 "some-root.json",
658 br#"{"format":3,"name":"v","version":"0.1.0"}"#,
659 ),
660 ]);
661 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
662 assert!(
663 matches!(err, ValidationError::UnknownFile(ref p) if p == "some-root.json"),
664 "unknown root file must be rejected as UnknownFile, got {err:?}"
665 );
666 }
667
668 #[test]
669 fn rejects_zip_slip() {
670 let zip = build_archive(&[
671 (".memstead/config.json", ok_config()),
672 ("../escape.md", b"# Escape\n"),
673 ]);
674 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
675 assert!(matches!(err, ValidationError::Zip(_)));
676 }
677
678 #[test]
679 fn rejects_nested_zip_slip() {
680 let zip = build_archive(&[
681 (".memstead/config.json", ok_config()),
682 ("subdir/../../escape.md", b"# Escape\n"),
683 ]);
684 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
685 assert!(matches!(err, ValidationError::Zip(_)));
686 }
687
688 fn crc32(data: &[u8]) -> u32 {
696 let mut crc = !0u32;
697 for &b in data {
698 crc ^= b as u32;
699 for _ in 0..8 {
700 let mask = (crc & 1).wrapping_neg();
701 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
702 }
703 }
704 !crc
705 }
706
707 fn raw_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
712 let mut out: Vec<u8> = Vec::new();
713 let mut central: Vec<u8> = Vec::new();
714 let mut count: u16 = 0;
715
716 for (name, content) in entries {
717 let name_bytes = name.as_bytes();
718 let crc = crc32(content);
719 let size = content.len() as u32;
720 let offset = out.len() as u32;
721
722 out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
724 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());
730 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());
733 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name_bytes);
735 out.extend_from_slice(content);
736
737 central.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
739 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());
746 central.extend_from_slice(&size.to_le_bytes());
747 central.extend_from_slice(&size.to_le_bytes());
748 central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
749 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);
756
757 count += 1;
758 }
759
760 let cd_offset = out.len() as u32;
761 let cd_size = central.len() as u32;
762 out.extend_from_slice(¢ral);
763
764 out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
766 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());
771 out.extend_from_slice(&cd_offset.to_le_bytes());
772 out.extend_from_slice(&0u16.to_le_bytes()); out
775 }
776
777 #[test]
786 fn rejects_absolute_path_entry() {
787 let zip = raw_zip(&[
788 (".memstead/config.json", ok_config()),
789 ("/etc/passwd.md", b"# Escape\n"),
790 ]);
791 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
792 match err {
795 ValidationError::Zip(reason) => {
796 assert!(
797 reason.contains("unsafe entry path"),
798 "unexpected reason: {reason}"
799 );
800 }
801 other => panic!("expected Zip(unsafe entry), got {other:?}"),
802 }
803 }
804
805 #[test]
806 fn rejects_windows_drive_letter_entry() {
807 let zip = raw_zip(&[
811 (".memstead/config.json", ok_config()),
812 ("C:\\evil.md", b"# Escape\n"),
813 ]);
814 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
815 assert!(matches!(err, ValidationError::Zip(_)));
816 }
817
818 #[test]
819 fn rejects_symlink_entry() {
820 let mut buf: Vec<u8> = Vec::new();
826 {
827 let cursor = Cursor::new(&mut buf);
828 let mut w = zip::ZipWriter::new(cursor);
829 w.start_file(".memstead/config.json", SimpleFileOptions::default())
830 .unwrap();
831 w.write_all(ok_config()).unwrap();
832 w.add_symlink("link.md", "target.md", SimpleFileOptions::default())
833 .unwrap();
834 w.finish().unwrap();
835 }
836 let err = extract_entries(&buf, &ValidatorLimits::DEFAULT).unwrap_err();
837 match err {
838 ValidationError::Symlink(name) => assert_eq!(name, "link.md"),
839 other => panic!("expected Symlink, got {other:?}"),
840 }
841 }
842
843 #[test]
844 fn rejects_compressed_archive_too_large() {
845 let mut limits = ValidatorLimits::DEFAULT;
846 limits.max_compressed_archive = 64;
847 let zip = build_archive(&[
848 (".memstead/config.json", ok_config()),
849 ("foo.md", b"# Foo\n"),
850 ]);
851 let err = extract_entries(&zip, &limits).unwrap_err();
852 assert!(matches!(
853 err,
854 ValidationError::SizeCapExceeded {
855 kind: SizeCapKind::CompressedArchive,
856 ..
857 }
858 ));
859 }
860
861 #[test]
862 fn rejects_single_entry_too_large() {
863 let mut limits = ValidatorLimits::DEFAULT;
864 limits.max_uncompressed_entry = 10;
865 let zip = build_archive(&[
866 (".memstead/config.json", ok_config()),
867 ("big.md", &[b'x'; 100]),
868 ]);
869 let err = extract_entries(&zip, &limits).unwrap_err();
870 assert!(matches!(
871 err,
872 ValidationError::SizeCapExceeded {
873 kind: SizeCapKind::UncompressedEntry,
874 ..
875 }
876 ));
877 }
878
879 #[test]
880 fn rejects_config_file_too_large() {
881 let mut limits = ValidatorLimits::DEFAULT;
882 limits.max_config_file = 10;
883 let zip = build_archive(&[
884 (".memstead/config.json", &[b'x'; 50]),
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::ConfigFile,
892 ..
893 }
894 ));
895 }
896
897 #[test]
898 fn rejects_uncompressed_sum_too_large() {
899 let mut limits = ValidatorLimits::DEFAULT;
900 limits.max_uncompressed_archive = 30;
901 let zip = build_archive(&[
902 (".memstead/config.json", ok_config()),
903 ("a.md", &[b'x'; 20]),
904 ("b.md", &[b'x'; 20]),
905 ]);
906 let err = extract_entries(&zip, &limits).unwrap_err();
907 assert!(matches!(
908 err,
909 ValidationError::SizeCapExceeded {
910 kind: SizeCapKind::UncompressedArchive,
911 ..
912 }
913 ));
914 }
915
916 #[test]
917 fn rejects_entry_count_too_large() {
918 let mut limits = ValidatorLimits::DEFAULT;
919 limits.max_file_count = 2;
920 let zip = build_archive(&[
921 (".memstead/config.json", ok_config()),
922 ("a.md", b"# A\n"),
923 ("b.md", b"# B\n"),
924 ]);
925 let err = extract_entries(&zip, &limits).unwrap_err();
926 assert!(matches!(
927 err,
928 ValidationError::SizeCapExceeded {
929 kind: SizeCapKind::EntryCount,
930 ..
931 }
932 ));
933 }
934
935 #[test]
936 fn rejects_path_too_long() {
937 let mut limits = ValidatorLimits::DEFAULT;
938 limits.max_path_length = 10;
939 let long_name = format!("{}.md", "a".repeat(20));
940 let zip = build_archive(&[
941 (".memstead/config.json", ok_config()),
942 (long_name.as_str(), b"# x\n"),
943 ]);
944 let err = extract_entries(&zip, &limits).unwrap_err();
945 assert!(matches!(err, ValidationError::PathTooLong { .. }));
946 }
947
948 #[test]
949 fn rejects_path_too_deep() {
950 let mut limits = ValidatorLimits::DEFAULT;
951 limits.max_path_depth = 2;
952 let zip = build_archive(&[
953 (".memstead/config.json", ok_config()),
954 ("a/b/c/d.md", b"# x\n"),
955 ]);
956 let err = extract_entries(&zip, &limits).unwrap_err();
957 assert!(matches!(err, ValidationError::PathTooDeep { .. }));
958 }
959
960 #[test]
961 fn rejects_non_utf8_markdown_content() {
962 let zip = build_archive(&[
963 (".memstead/config.json", ok_config()),
964 ("bad.md", &[0xff, 0xfe, 0xff]),
965 ]);
966 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
967 assert!(matches!(err, ValidationError::Utf8 { .. }));
968 }
969}