1use std::io::{Cursor, Read};
10
11use memstead_schema::{
12 ARCHIVE_CONFIG_PATH, ARCHIVE_META_DIR, ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX,
13};
14
15use super::{SizeCapKind, ValidationError, ValidatorLimits};
16
17#[derive(Debug)]
21pub struct MarkdownEntry {
22 pub path: String,
23 pub content: String,
24}
25
26#[derive(Debug)]
36pub struct SchemaFile {
37 pub archive_path: String,
38 pub content: String,
39}
40
41#[derive(Debug)]
42pub struct ArchiveEntries {
43 pub config_bytes: Vec<u8>,
44 pub markdown_files: Vec<MarkdownEntry>,
45 pub schema_files: Vec<SchemaFile>,
46 pub provenance_bytes: Option<Vec<u8>>,
52}
53
54pub fn extract_entries(
58 bytes: &[u8],
59 limits: &ValidatorLimits,
60) -> Result<ArchiveEntries, ValidationError> {
61 if bytes.len() as u64 > limits.max_compressed_archive {
62 return Err(ValidationError::SizeCapExceeded {
63 kind: SizeCapKind::CompressedArchive,
64 got: bytes.len() as u64,
65 limit: limits.max_compressed_archive,
66 });
67 }
68
69 let cursor = Cursor::new(bytes);
70 let mut archive =
71 zip::ZipArchive::new(cursor).map_err(|e| ValidationError::Zip(e.to_string()))?;
72
73 if archive.len() as u32 > limits.max_file_count {
74 return Err(ValidationError::SizeCapExceeded {
75 kind: SizeCapKind::EntryCount,
76 got: archive.len() as u64,
77 limit: limits.max_file_count as u64,
78 });
79 }
80
81 let mut config_bytes: Option<Vec<u8>> = None;
82 let mut markdown_files: Vec<MarkdownEntry> = Vec::new();
83 let mut schema_files: Vec<SchemaFile> = Vec::new();
84 let mut provenance_bytes: Option<Vec<u8>> = None;
85 let mut seen_paths: Vec<String> = Vec::new();
86 let mut uncompressed_total: u64 = 0;
87
88 for i in 0..archive.len() {
89 let mut entry = archive
90 .by_index(i)
91 .map_err(|e| ValidationError::Zip(e.to_string()))?;
92
93 if entry.is_dir() {
94 continue;
95 }
96
97 if entry.is_symlink() {
98 return Err(ValidationError::Symlink(entry.name().to_string()));
99 }
100
101 let raw_name = entry.name();
107 if raw_name.starts_with('/') || raw_name.starts_with('\\') {
108 return Err(ValidationError::Zip(format!(
109 "unsafe entry path: {raw_name}"
110 )));
111 }
112 let raw_bytes = raw_name.as_bytes();
113 if raw_bytes.len() >= 2 && raw_bytes[1] == b':' && raw_bytes[0].is_ascii_alphabetic() {
114 return Err(ValidationError::Zip(format!(
115 "unsafe entry path: {raw_name}"
116 )));
117 }
118
119 let enclosed = entry
120 .enclosed_name()
121 .ok_or_else(|| ValidationError::Zip(format!("unsafe entry path: {}", entry.name())))?;
122 let path_string = enclosed
123 .to_str()
124 .ok_or_else(|| ValidationError::Zip(format!("non-UTF-8 entry path: {}", entry.name())))?
125 .replace('\\', "/");
126
127 if path_string.len() > limits.max_path_length {
128 return Err(ValidationError::PathTooLong {
129 path: path_string.clone(),
130 len: path_string.len(),
131 limit: limits.max_path_length,
132 });
133 }
134
135 let depth = path_string.split('/').count();
136 if depth > limits.max_path_depth {
137 return Err(ValidationError::PathTooDeep {
138 path: path_string.clone(),
139 depth,
140 limit: limits.max_path_depth,
141 });
142 }
143
144 if seen_paths.iter().any(|p| p == &path_string) {
145 return Err(ValidationError::DuplicateEntry(path_string));
146 }
147
148 let meta_dir_prefix = format!("{ARCHIVE_META_DIR}/");
149 let is_config = path_string == ARCHIVE_CONFIG_PATH;
150 let is_schema = is_schema_path(&path_string);
151 let is_provenance = path_string == ARCHIVE_PROVENANCE_PATH;
152 let is_markdown =
156 path_string.ends_with(".md") && !path_string.starts_with(&meta_dir_prefix);
157 let is_ignored_meta = path_string.starts_with(&meta_dir_prefix)
174 && !is_config
175 && !is_schema
176 && !is_provenance
177 && !path_string.ends_with(".md")
178 && !path_string.starts_with(ARCHIVE_SCHEMA_PREFIX);
179 if !is_config && !is_markdown && !is_schema && !is_provenance && !is_ignored_meta {
180 return Err(ValidationError::UnknownFile(path_string));
181 }
182
183 let per_entry_cap = if is_config {
184 limits.max_config_file
185 } else {
186 limits.max_uncompressed_entry
187 };
188
189 let mut buf = Vec::new();
190 let mut reader = (&mut entry).take(per_entry_cap + 1);
191 reader
192 .read_to_end(&mut buf)
193 .map_err(|e| ValidationError::Zip(e.to_string()))?;
194
195 if buf.len() as u64 > per_entry_cap {
196 let kind = if is_config {
197 SizeCapKind::ConfigFile
198 } else {
199 SizeCapKind::UncompressedEntry
200 };
201 return Err(ValidationError::SizeCapExceeded {
202 kind,
203 got: buf.len() as u64,
204 limit: per_entry_cap,
205 });
206 }
207
208 uncompressed_total = uncompressed_total.saturating_add(buf.len() as u64);
209 if uncompressed_total > limits.max_uncompressed_archive {
210 return Err(ValidationError::SizeCapExceeded {
211 kind: SizeCapKind::UncompressedArchive,
212 got: uncompressed_total,
213 limit: limits.max_uncompressed_archive,
214 });
215 }
216
217 seen_paths.push(path_string.clone());
218
219 if is_ignored_meta {
222 continue;
223 }
224
225 if is_config {
226 config_bytes = Some(buf);
227 } else if is_provenance {
228 provenance_bytes = Some(buf);
233 } else {
234 let content = match std::str::from_utf8(&buf) {
235 Ok(s) => s.to_string(),
236 Err(e) => {
237 return Err(ValidationError::Utf8 {
238 path: path_string,
239 offset: e.valid_up_to(),
240 });
241 }
242 };
243 let content = content.replace("\r\n", "\n");
249 if is_schema {
250 schema_files.push(SchemaFile {
251 archive_path: path_string,
252 content,
253 });
254 } else {
255 markdown_files.push(MarkdownEntry {
256 path: path_string,
257 content,
258 });
259 }
260 }
261 }
262
263 let config_bytes = config_bytes.ok_or(ValidationError::MissingConfig)?;
264
265 markdown_files.sort_by(|a, b| a.path.cmp(&b.path));
266 schema_files.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
267
268 Ok(ArchiveEntries {
269 config_bytes,
270 markdown_files,
271 schema_files,
272 provenance_bytes,
273 })
274}
275
276fn is_schema_path(path: &str) -> bool {
283 let Some(rest) = path.strip_prefix(ARCHIVE_SCHEMA_PREFIX) else {
284 return false;
285 };
286 if rest == "schema.yaml" {
287 return true;
288 }
289 let Some(rest) = rest.strip_prefix("types/") else {
290 return false;
291 };
292 if !rest.ends_with(".yaml") {
293 return false;
294 }
295 let stem = &rest[..rest.len() - ".yaml".len()];
296 !stem.is_empty() && !stem.contains('/')
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::io::Write;
303 use zip::write::SimpleFileOptions;
304
305 fn build_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
308 let mut buf = Vec::new();
309 {
310 let cursor = Cursor::new(&mut buf);
311 let mut w = zip::ZipWriter::new(cursor);
312 let options =
313 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
314 for (name, content) in entries {
315 w.start_file(*name, options).unwrap();
316 w.write_all(content).unwrap();
317 }
318 w.finish().unwrap();
319 }
320 buf
321 }
322
323 fn ok_config() -> &'static [u8] {
324 br#"{"format":3,"name":"v","version":"0.1.0","schema":"default@1.0.0"}"#
325 }
326
327 #[test]
328 fn accepts_minimal_valid_archive() {
329 let zip = build_archive(&[
330 (".memstead/config.json", ok_config()),
331 ("foo.md", b"# Foo\n"),
332 ]);
333 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
334 assert_eq!(entries.markdown_files.len(), 1);
335 assert_eq!(entries.markdown_files[0].path, "foo.md");
336 assert!(entries.provenance_bytes.is_none());
339 }
340
341 #[test]
346 fn recognises_provenance_member() {
347 let prov = br#"{"format":1,"history":"summarised","entities":{}}"#;
348 let zip = build_archive(&[
349 (".memstead/config.json", ok_config()),
350 (".memstead/provenance.json", prov),
351 ("foo.md", b"# Foo\n"),
352 ]);
353 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
354 assert_eq!(
355 entries.provenance_bytes.as_deref(),
356 Some(&prov[..]),
357 "provenance bytes surface verbatim"
358 );
359 assert_eq!(
360 entries.markdown_files.len(),
361 1,
362 "provenance is not an entity"
363 );
364 }
365
366 #[test]
374 fn tolerates_unknown_meta_member_but_rejects_unknown_root_member() {
375 let tolerated = build_archive(&[
376 (".memstead/config.json", ok_config()),
377 (".memstead/future-payload.json", br#"{"x":1}"#),
378 ("foo.md", b"# Foo\n"),
379 ]);
380 let entries = extract_entries(&tolerated, &ValidatorLimits::DEFAULT)
381 .expect("unknown meta member must be tolerated");
382 assert_eq!(entries.markdown_files.len(), 1);
383 assert!(
384 entries.provenance_bytes.is_none(),
385 "an unrecognised meta member is ignored, not surfaced as provenance"
386 );
387
388 let rejected = build_archive(&[
389 (".memstead/config.json", ok_config()),
390 ("stray.txt", b"not allowed at root"),
391 ("foo.md", b"# Foo\n"),
392 ]);
393 let err = extract_entries(&rejected, &ValidatorLimits::DEFAULT).unwrap_err();
394 assert!(
395 matches!(err, ValidationError::UnknownFile(ref p) if p == "stray.txt"),
396 "unknown non-meta member must still be rejected, got {err:?}"
397 );
398 }
399
400 #[test]
404 fn rejects_mixed_meta_dir_layout() {
405 let zip = build_archive(&[
406 (".memstead/config.json", ok_config()),
407 (".other/schema/schema.yaml", b"name: default\n"),
408 ("foo.md", b"# Foo\n"),
409 ]);
410 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
411 match err {
412 ValidationError::UnknownFile(path) => {
413 assert!(path.starts_with(".other/"), "path={path}");
414 }
415 other => panic!("expected UnknownFile for the foreign meta member, got {other:?}"),
416 }
417 }
418
419 #[test]
422 fn rejects_markdown_inside_meta_dir() {
423 let zip = build_archive(&[
424 (".memstead/config.json", ok_config()),
425 (".memstead/notes.md", b"# not an entity\n"),
426 ]);
427 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
428 assert!(
429 matches!(err, ValidationError::UnknownFile(_)),
430 "got {err:?}"
431 );
432 }
433
434 #[test]
435 fn markdown_files_are_sorted() {
436 let zip = build_archive(&[
437 (".memstead/config.json", ok_config()),
438 ("z.md", b"# Z\n"),
439 ("a.md", b"# A\n"),
440 ("m.md", b"# M\n"),
441 ]);
442 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
443 let paths: Vec<_> = entries
444 .markdown_files
445 .iter()
446 .map(|e| e.path.as_str())
447 .collect();
448 assert_eq!(paths, vec!["a.md", "m.md", "z.md"]);
449 }
450
451 #[test]
452 fn rejects_corrupt_zip() {
453 let err = extract_entries(b"not a zip at all", &ValidatorLimits::DEFAULT).unwrap_err();
454 assert!(matches!(err, ValidationError::Zip(_)));
455 }
456
457 #[test]
458 fn rejects_missing_config() {
459 let zip = build_archive(&[("foo.md", b"# Foo\n")]);
460 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
461 assert!(matches!(err, ValidationError::MissingConfig));
462 }
463
464 #[test]
465 fn rejects_non_markdown_non_config_file() {
466 let zip = build_archive(&[
467 (".memstead/config.json", ok_config()),
468 ("binary.exe", b"\x7fELF"),
469 ]);
470 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
471 match err {
472 ValidationError::UnknownFile(p) => assert_eq!(p, "binary.exe"),
473 other => panic!("expected UnknownFile, got {other:?}"),
474 }
475 }
476
477 #[test]
478 fn accepts_schema_package_entries() {
479 let zip = build_archive(&[
484 (".memstead/config.json", ok_config()),
485 ("foo.md", b"# Foo\n"),
486 (".memstead/schema/schema.yaml", b"name: default\n"),
487 (".memstead/schema/types/spec.yaml", b"name: spec\n"),
488 ]);
489 let entries = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap();
490 assert_eq!(entries.schema_files.len(), 2);
491 let paths: Vec<&str> = entries
492 .schema_files
493 .iter()
494 .map(|s| s.archive_path.as_str())
495 .collect();
496 assert_eq!(
497 paths,
498 vec![
499 ".memstead/schema/schema.yaml",
500 ".memstead/schema/types/spec.yaml"
501 ]
502 );
503 }
504
505 #[test]
506 fn rejects_unknown_schema_subpath() {
507 let zip = build_archive(&[
511 (".memstead/config.json", ok_config()),
512 (".memstead/schema/unexpected.json", b"{}"),
513 ]);
514 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
515 assert!(matches!(err, ValidationError::UnknownFile(_)));
516 }
517
518 #[test]
519 fn rejects_nested_schema_type_file() {
520 let zip = build_archive(&[
521 (".memstead/config.json", ok_config()),
522 (
523 ".memstead/schema/types/nested/subtype.yaml",
524 b"name: subtype\n",
525 ),
526 ]);
527 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
528 assert!(matches!(err, ValidationError::UnknownFile(_)));
529 }
530
531 #[test]
534 fn rejects_unknown_root_file() {
535 let zip = build_archive(&[
536 (".memstead/config.json", ok_config()),
537 (
538 "some-root.json",
539 br#"{"format":3,"name":"v","version":"0.1.0"}"#,
540 ),
541 ]);
542 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
543 assert!(
544 matches!(err, ValidationError::UnknownFile(ref p) if p == "some-root.json"),
545 "unknown root file must be rejected as UnknownFile, got {err:?}"
546 );
547 }
548
549 #[test]
550 fn rejects_zip_slip() {
551 let zip = build_archive(&[
552 (".memstead/config.json", ok_config()),
553 ("../escape.md", b"# Escape\n"),
554 ]);
555 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
556 assert!(matches!(err, ValidationError::Zip(_)));
557 }
558
559 #[test]
560 fn rejects_nested_zip_slip() {
561 let zip = build_archive(&[
562 (".memstead/config.json", ok_config()),
563 ("subdir/../../escape.md", b"# Escape\n"),
564 ]);
565 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
566 assert!(matches!(err, ValidationError::Zip(_)));
567 }
568
569 fn crc32(data: &[u8]) -> u32 {
577 let mut crc = !0u32;
578 for &b in data {
579 crc ^= b as u32;
580 for _ in 0..8 {
581 let mask = (crc & 1).wrapping_neg();
582 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
583 }
584 }
585 !crc
586 }
587
588 fn raw_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
593 let mut out: Vec<u8> = Vec::new();
594 let mut central: Vec<u8> = Vec::new();
595 let mut count: u16 = 0;
596
597 for (name, content) in entries {
598 let name_bytes = name.as_bytes();
599 let crc = crc32(content);
600 let size = content.len() as u32;
601 let offset = out.len() as u32;
602
603 out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
605 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());
611 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());
614 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name_bytes);
616 out.extend_from_slice(content);
617
618 central.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
620 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());
627 central.extend_from_slice(&size.to_le_bytes());
628 central.extend_from_slice(&size.to_le_bytes());
629 central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
630 central.extend_from_slice(&0u16.to_le_bytes()); 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);
637
638 count += 1;
639 }
640
641 let cd_offset = out.len() as u32;
642 let cd_size = central.len() as u32;
643 out.extend_from_slice(¢ral);
644
645 out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
647 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());
652 out.extend_from_slice(&cd_offset.to_le_bytes());
653 out.extend_from_slice(&0u16.to_le_bytes()); out
656 }
657
658 #[test]
667 fn rejects_absolute_path_entry() {
668 let zip = raw_zip(&[
669 (".memstead/config.json", ok_config()),
670 ("/etc/passwd.md", b"# Escape\n"),
671 ]);
672 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
673 match err {
676 ValidationError::Zip(reason) => {
677 assert!(
678 reason.contains("unsafe entry path"),
679 "unexpected reason: {reason}"
680 );
681 }
682 other => panic!("expected Zip(unsafe entry), got {other:?}"),
683 }
684 }
685
686 #[test]
687 fn rejects_windows_drive_letter_entry() {
688 let zip = raw_zip(&[
692 (".memstead/config.json", ok_config()),
693 ("C:\\evil.md", b"# Escape\n"),
694 ]);
695 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
696 assert!(matches!(err, ValidationError::Zip(_)));
697 }
698
699 #[test]
700 fn rejects_symlink_entry() {
701 let mut buf: Vec<u8> = Vec::new();
707 {
708 let cursor = Cursor::new(&mut buf);
709 let mut w = zip::ZipWriter::new(cursor);
710 w.start_file(".memstead/config.json", SimpleFileOptions::default())
711 .unwrap();
712 w.write_all(ok_config()).unwrap();
713 w.add_symlink("link.md", "target.md", SimpleFileOptions::default())
714 .unwrap();
715 w.finish().unwrap();
716 }
717 let err = extract_entries(&buf, &ValidatorLimits::DEFAULT).unwrap_err();
718 match err {
719 ValidationError::Symlink(name) => assert_eq!(name, "link.md"),
720 other => panic!("expected Symlink, got {other:?}"),
721 }
722 }
723
724 #[test]
725 fn rejects_compressed_archive_too_large() {
726 let mut limits = ValidatorLimits::DEFAULT;
727 limits.max_compressed_archive = 64;
728 let zip = build_archive(&[
729 (".memstead/config.json", ok_config()),
730 ("foo.md", b"# Foo\n"),
731 ]);
732 let err = extract_entries(&zip, &limits).unwrap_err();
733 assert!(matches!(
734 err,
735 ValidationError::SizeCapExceeded {
736 kind: SizeCapKind::CompressedArchive,
737 ..
738 }
739 ));
740 }
741
742 #[test]
743 fn rejects_single_entry_too_large() {
744 let mut limits = ValidatorLimits::DEFAULT;
745 limits.max_uncompressed_entry = 10;
746 let zip = build_archive(&[
747 (".memstead/config.json", ok_config()),
748 ("big.md", &[b'x'; 100]),
749 ]);
750 let err = extract_entries(&zip, &limits).unwrap_err();
751 assert!(matches!(
752 err,
753 ValidationError::SizeCapExceeded {
754 kind: SizeCapKind::UncompressedEntry,
755 ..
756 }
757 ));
758 }
759
760 #[test]
761 fn rejects_config_file_too_large() {
762 let mut limits = ValidatorLimits::DEFAULT;
763 limits.max_config_file = 10;
764 let zip = build_archive(&[
765 (".memstead/config.json", &[b'x'; 50]),
766 ("foo.md", b"# Foo\n"),
767 ]);
768 let err = extract_entries(&zip, &limits).unwrap_err();
769 assert!(matches!(
770 err,
771 ValidationError::SizeCapExceeded {
772 kind: SizeCapKind::ConfigFile,
773 ..
774 }
775 ));
776 }
777
778 #[test]
779 fn rejects_uncompressed_sum_too_large() {
780 let mut limits = ValidatorLimits::DEFAULT;
781 limits.max_uncompressed_archive = 30;
782 let zip = build_archive(&[
783 (".memstead/config.json", ok_config()),
784 ("a.md", &[b'x'; 20]),
785 ("b.md", &[b'x'; 20]),
786 ]);
787 let err = extract_entries(&zip, &limits).unwrap_err();
788 assert!(matches!(
789 err,
790 ValidationError::SizeCapExceeded {
791 kind: SizeCapKind::UncompressedArchive,
792 ..
793 }
794 ));
795 }
796
797 #[test]
798 fn rejects_entry_count_too_large() {
799 let mut limits = ValidatorLimits::DEFAULT;
800 limits.max_file_count = 2;
801 let zip = build_archive(&[
802 (".memstead/config.json", ok_config()),
803 ("a.md", b"# A\n"),
804 ("b.md", b"# B\n"),
805 ]);
806 let err = extract_entries(&zip, &limits).unwrap_err();
807 assert!(matches!(
808 err,
809 ValidationError::SizeCapExceeded {
810 kind: SizeCapKind::EntryCount,
811 ..
812 }
813 ));
814 }
815
816 #[test]
817 fn rejects_path_too_long() {
818 let mut limits = ValidatorLimits::DEFAULT;
819 limits.max_path_length = 10;
820 let long_name = format!("{}.md", "a".repeat(20));
821 let zip = build_archive(&[
822 (".memstead/config.json", ok_config()),
823 (long_name.as_str(), b"# x\n"),
824 ]);
825 let err = extract_entries(&zip, &limits).unwrap_err();
826 assert!(matches!(err, ValidationError::PathTooLong { .. }));
827 }
828
829 #[test]
830 fn rejects_path_too_deep() {
831 let mut limits = ValidatorLimits::DEFAULT;
832 limits.max_path_depth = 2;
833 let zip = build_archive(&[
834 (".memstead/config.json", ok_config()),
835 ("a/b/c/d.md", b"# x\n"),
836 ]);
837 let err = extract_entries(&zip, &limits).unwrap_err();
838 assert!(matches!(err, ValidationError::PathTooDeep { .. }));
839 }
840
841 #[test]
842 fn rejects_non_utf8_markdown_content() {
843 let zip = build_archive(&[
844 (".memstead/config.json", ok_config()),
845 ("bad.md", &[0xff, 0xfe, 0xff]),
846 ]);
847 let err = extract_entries(&zip, &ValidatorLimits::DEFAULT).unwrap_err();
848 assert!(matches!(err, ValidationError::Utf8 { .. }));
849 }
850}