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 let sidecar = crate::anchor::AnchorSidecar::from_bytes(&buf).map_err(|e| {
280 ValidationError::InvalidAnchorsMember {
281 reason: e.to_string(),
282 }
283 })?;
284 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 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
336fn 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 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 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 assert!(entries.provenance_bytes.is_none());
404 }
405
406 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 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 out.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
760 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());
766 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());
769 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name_bytes);
771 out.extend_from_slice(content);
772
773 central.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
775 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());
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()); 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);
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(¢ral);
799
800 out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
802 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());
807 out.extend_from_slice(&cd_offset.to_le_bytes());
808 out.extend_from_slice(&0u16.to_le_bytes()); out
811 }
812
813 #[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 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 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 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}