1use std::{
9 collections::BTreeSet,
10 fs::{self, File, OpenOptions},
11 io::{self, BufReader, BufWriter, Read, Write},
12 path::{Component, Path, PathBuf},
13 sync::atomic::{AtomicU64, Ordering},
14};
15
16use flate2::{Compression, GzBuilder, bufread::GzDecoder};
17use serde::{Deserialize, Serialize};
18use supercov_contracts::{EVIDENCE_ARCHIVE_MAGIC, EVIDENCE_ARCHIVE_SCHEMA_VERSION};
19
20static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
21const MAX_ENTRY_HEADER_BYTES: usize = 64 * 1024;
22
23#[derive(Debug)]
24pub enum EvidenceArchiveError {
25 Io(io::Error),
26 Json(serde_json::Error),
27 InvalidMagic,
28 InvalidHeader(&'static str),
29 InvalidPath(String),
30 DuplicatePath(String),
31 UnsortedPath { previous: String, current: String },
32 MissingManifest,
33 MissingFrontend,
34 MissingCoverageModel,
35 Truncated(&'static str),
36 TrailingCompressedData,
37 UnsupportedSource(PathBuf),
38 PathOutsideSource { source: PathBuf, path: PathBuf },
39 SizeOverflow,
40}
41
42impl std::fmt::Display for EvidenceArchiveError {
43 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Self::Io(error) => write!(formatter, "{error}"),
46 Self::Json(error) => write!(formatter, "{error}"),
47 Self::InvalidMagic => write!(formatter, "unsupported Supercov evidence archive"),
48 Self::InvalidHeader(reason) => {
49 write!(
50 formatter,
51 "invalid Supercov evidence archive header: {reason}"
52 )
53 }
54 Self::InvalidPath(path) => {
55 write!(
56 formatter,
57 "invalid Supercov evidence archive path: {path:?}"
58 )
59 }
60 Self::DuplicatePath(path) => {
61 write!(
62 formatter,
63 "duplicate Supercov evidence archive path: {path}"
64 )
65 }
66 Self::UnsortedPath { previous, current } => write!(
67 formatter,
68 "unsorted Supercov evidence archive paths: {previous} before {current}",
69 ),
70 Self::MissingManifest => write!(
71 formatter,
72 "Supercov evidence archive is missing manifest.json",
73 ),
74 Self::MissingFrontend => write!(
75 formatter,
76 "Supercov evidence archive is missing frontend.json",
77 ),
78 Self::MissingCoverageModel => write!(
79 formatter,
80 "Supercov evidence archive is missing coverage-model.json",
81 ),
82 Self::Truncated(part) => {
83 write!(formatter, "truncated Supercov evidence archive {part}")
84 }
85 Self::TrailingCompressedData => write!(
86 formatter,
87 "Supercov evidence archive contains trailing compressed data",
88 ),
89 Self::UnsupportedSource(path) => {
90 write!(
91 formatter,
92 "unsupported raw evidence entry: {}",
93 path.display()
94 )
95 }
96 Self::PathOutsideSource { source, path } => write!(
97 formatter,
98 "evidence path {} is outside source {}",
99 path.display(),
100 source.display(),
101 ),
102 Self::SizeOverflow => write!(formatter, "evidence archive size exceeds its format"),
103 }
104 }
105}
106
107impl std::error::Error for EvidenceArchiveError {}
108
109impl From<io::Error> for EvidenceArchiveError {
110 fn from(error: io::Error) -> Self {
111 Self::Io(error)
112 }
113}
114
115impl From<serde_json::Error> for EvidenceArchiveError {
116 fn from(error: serde_json::Error) -> Self {
117 Self::Json(error)
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct EvidenceArchiveEntry {
123 pub path: String,
124 pub contents: Vec<u8>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct EvidenceArchiveMetadata {
130 pub schema_version: u32,
131 pub format: &'static str,
132 pub file: &'static str,
133 pub files: usize,
134 pub uncompressed_bytes: u64,
135 pub compressed_bytes: u64,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum EvidenceArchiveSource {
140 Directory {
141 directory: PathBuf,
142 prefix: Option<String>,
143 },
144 File {
145 file: PathBuf,
146 path: String,
147 },
148}
149
150#[derive(Debug, Deserialize, Serialize)]
151#[serde(deny_unknown_fields)]
152struct EntryHeader {
153 path: String,
154 bytes: u64,
155}
156
157struct CountingWriter<W> {
158 inner: W,
159 written: u64,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(crate) enum EvidenceArchiveWriteFault {
164 NoSpaceAfterBytes(u64),
165}
166
167struct FaultingWriter<W> {
168 inner: W,
169 fault: Option<EvidenceArchiveWriteFault>,
170 written: u64,
171}
172
173impl<W> FaultingWriter<W> {
174 fn new(inner: W, fault: Option<EvidenceArchiveWriteFault>) -> Self {
175 Self {
176 inner,
177 fault,
178 written: 0,
179 }
180 }
181}
182
183impl<W: Write> Write for FaultingWriter<W> {
184 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
185 let allowed = match self.fault {
186 Some(EvidenceArchiveWriteFault::NoSpaceAfterBytes(limit)) => {
187 if self.written >= limit {
188 return Err(io::Error::new(
189 io::ErrorKind::StorageFull,
190 "injected ENOSPC while writing evidence archive",
191 ));
192 }
193 usize::try_from(limit - self.written)
194 .unwrap_or(usize::MAX)
195 .min(buffer.len())
196 }
197 None => buffer.len(),
198 };
199 let written = self.inner.write(&buffer[..allowed])?;
200 self.written = self
201 .written
202 .checked_add(written as u64)
203 .ok_or_else(|| io::Error::other("evidence archive size overflow"))?;
204 Ok(written)
205 }
206
207 fn flush(&mut self) -> io::Result<()> {
208 self.inner.flush()
209 }
210}
211
212impl<W> CountingWriter<W> {
213 fn new(inner: W) -> Self {
214 Self { inner, written: 0 }
215 }
216}
217
218impl<W: Write> Write for CountingWriter<W> {
219 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
220 let written = self.inner.write(buffer)?;
221 self.written = self
222 .written
223 .checked_add(written as u64)
224 .ok_or_else(|| io::Error::other("evidence archive size overflow"))?;
225 Ok(written)
226 }
227
228 fn flush(&mut self) -> io::Result<()> {
229 self.inner.flush()
230 }
231}
232
233fn validate_archive_path(path: &str) -> Result<(), EvidenceArchiveError> {
234 if path.is_empty()
235 || path.starts_with('/')
236 || path.ends_with('/')
237 || path.contains('\\')
238 || path.contains('\0')
239 || path
240 .split('/')
241 .any(|part| part.is_empty() || part == "." || part == "..")
242 {
243 return Err(EvidenceArchiveError::InvalidPath(path.to_owned()));
244 }
245 Ok(())
246}
247
248fn validate_prefix(prefix: &str) -> Result<(), EvidenceArchiveError> {
249 validate_archive_path(prefix)
250}
251
252fn path_from_relative(path: &Path) -> Result<String, EvidenceArchiveError> {
253 let mut parts = Vec::new();
254 for component in path.components() {
255 match component {
256 Component::Normal(part) => {
257 let part = part
258 .to_str()
259 .ok_or_else(|| EvidenceArchiveError::InvalidPath(path.display().to_string()))?;
260 parts.push(part);
261 }
262 _ => {
263 return Err(EvidenceArchiveError::InvalidPath(
264 path.display().to_string(),
265 ));
266 }
267 }
268 }
269 let archive_path = parts.join("/");
270 validate_archive_path(&archive_path)?;
271 Ok(archive_path)
272}
273
274fn collect_directory(
275 root: &Path,
276 current: &Path,
277 prefix: Option<&str>,
278 entries: &mut Vec<EvidenceArchiveEntry>,
279) -> Result<(), EvidenceArchiveError> {
280 let mut children = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
281 children.sort_by_key(|entry| entry.file_name());
282 for child in children {
283 let path = child.path();
284 let metadata = fs::symlink_metadata(&path)?;
285 if metadata.is_dir() {
286 collect_directory(root, &path, prefix, entries)?;
287 continue;
288 }
289 if !metadata.is_file() {
290 return Err(EvidenceArchiveError::UnsupportedSource(path));
291 }
292 let relative =
293 path.strip_prefix(root)
294 .map_err(|_| EvidenceArchiveError::PathOutsideSource {
295 source: root.to_owned(),
296 path: path.clone(),
297 })?;
298 let relative = path_from_relative(relative)?;
299 let archive_path = match prefix {
300 Some(prefix) => format!("{prefix}/{relative}"),
301 None => relative,
302 };
303 validate_archive_path(&archive_path)?;
304 entries.push(EvidenceArchiveEntry {
305 path: archive_path,
306 contents: fs::read(path)?,
307 });
308 }
309 Ok(())
310}
311
312pub fn collect_sources(
313 sources: &[EvidenceArchiveSource],
314) -> Result<Vec<EvidenceArchiveEntry>, EvidenceArchiveError> {
315 let mut entries = Vec::new();
316 for source in sources {
317 match source {
318 EvidenceArchiveSource::Directory { directory, prefix } => {
319 if let Some(prefix) = prefix {
320 validate_prefix(prefix)?;
321 }
322 match fs::symlink_metadata(directory) {
323 Ok(metadata) if metadata.is_dir() => {
324 collect_directory(directory, directory, prefix.as_deref(), &mut entries)?
325 }
326 Ok(_) => {
327 return Err(EvidenceArchiveError::UnsupportedSource(directory.clone()));
328 }
329 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
330 Err(error) => return Err(error.into()),
331 }
332 }
333 EvidenceArchiveSource::File { file, path } => {
334 validate_archive_path(path)?;
335 let metadata = fs::symlink_metadata(file)?;
336 if !metadata.is_file() {
337 return Err(EvidenceArchiveError::UnsupportedSource(file.clone()));
338 }
339 entries.push(EvidenceArchiveEntry {
340 path: path.clone(),
341 contents: fs::read(file)?,
342 });
343 }
344 }
345 }
346 canonicalize_entries(entries)
347}
348
349fn canonicalize_entries(
350 mut entries: Vec<EvidenceArchiveEntry>,
351) -> Result<Vec<EvidenceArchiveEntry>, EvidenceArchiveError> {
352 for entry in &entries {
353 validate_archive_path(&entry.path)?;
354 }
355 entries.sort_by(|left, right| left.path.cmp(&right.path));
356 for pair in entries.windows(2) {
357 if pair[0].path == pair[1].path {
358 return Err(EvidenceArchiveError::DuplicatePath(pair[0].path.clone()));
359 }
360 }
361 if !entries.iter().any(|entry| entry.path == "manifest.json") {
362 return Err(EvidenceArchiveError::MissingManifest);
363 }
364 Ok(entries)
365}
366
367fn write_framed_with_magic<W: Write>(
368 entries: &[EvidenceArchiveEntry],
369 output: W,
370 magic: &str,
371) -> Result<(W, u64), EvidenceArchiveError> {
372 let mut writer = CountingWriter::new(output);
373 writer.write_all(magic.as_bytes())?;
374 for entry in entries {
375 let header = EntryHeader {
376 path: entry.path.clone(),
377 bytes: u64::try_from(entry.contents.len())
378 .map_err(|_| EvidenceArchiveError::SizeOverflow)?,
379 };
380 let mut encoded = serde_json::to_vec(&header)?;
381 encoded.push(b'\n');
382 let header_size =
383 u32::try_from(encoded.len()).map_err(|_| EvidenceArchiveError::SizeOverflow)?;
384 writer.write_all(&header_size.to_be_bytes())?;
385 writer.write_all(&encoded)?;
386 writer.write_all(&entry.contents)?;
387 }
388 writer.flush()?;
389 Ok((writer.inner, writer.written))
390}
391
392fn temporary_path(destination: &Path, sequence: u64) -> Result<PathBuf, EvidenceArchiveError> {
393 let name = destination
394 .file_name()
395 .and_then(|name| name.to_str())
396 .ok_or_else(|| EvidenceArchiveError::InvalidPath(destination.display().to_string()))?;
397 Ok(destination.with_file_name(format!(".{name}.{}.{}.tmp", std::process::id(), sequence,)))
398}
399
400fn sync_parent(path: &Path) {
401 if let Some(parent) = path.parent()
402 && let Ok(directory) = File::open(parent)
403 {
404 let _ = directory.sync_all();
405 }
406}
407
408pub fn write_archive(
409 entries: Vec<EvidenceArchiveEntry>,
410 destination: &Path,
411) -> Result<EvidenceArchiveMetadata, EvidenceArchiveError> {
412 let paths = entries
413 .iter()
414 .map(|entry| entry.path.as_str())
415 .collect::<BTreeSet<_>>();
416 if !paths.contains("frontend.json") {
417 return Err(EvidenceArchiveError::MissingFrontend);
418 }
419 if !paths.contains("coverage-model.json") {
420 return Err(EvidenceArchiveError::MissingCoverageModel);
421 }
422 write_archive_version(
423 entries,
424 destination,
425 EVIDENCE_ARCHIVE_SCHEMA_VERSION,
426 EVIDENCE_ARCHIVE_MAGIC,
427 None,
428 )
429}
430
431pub(crate) fn write_archive_with_fault(
432 entries: Vec<EvidenceArchiveEntry>,
433 destination: &Path,
434 fault: EvidenceArchiveWriteFault,
435) -> Result<EvidenceArchiveMetadata, EvidenceArchiveError> {
436 let paths = entries
437 .iter()
438 .map(|entry| entry.path.as_str())
439 .collect::<BTreeSet<_>>();
440 if !paths.contains("frontend.json") {
441 return Err(EvidenceArchiveError::MissingFrontend);
442 }
443 if !paths.contains("coverage-model.json") {
444 return Err(EvidenceArchiveError::MissingCoverageModel);
445 }
446 write_archive_version(
447 entries,
448 destination,
449 EVIDENCE_ARCHIVE_SCHEMA_VERSION,
450 EVIDENCE_ARCHIVE_MAGIC,
451 Some(fault),
452 )
453}
454
455fn write_archive_version(
456 entries: Vec<EvidenceArchiveEntry>,
457 destination: &Path,
458 schema_version: u32,
459 magic: &str,
460 fault: Option<EvidenceArchiveWriteFault>,
461) -> Result<EvidenceArchiveMetadata, EvidenceArchiveError> {
462 let entries = canonicalize_entries(entries)?;
463 let parent = destination
464 .parent()
465 .ok_or_else(|| EvidenceArchiveError::InvalidPath(destination.display().to_string()))?;
466 fs::create_dir_all(parent)?;
467
468 let (temporary, file) = loop {
469 let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
470 let temporary = temporary_path(destination, sequence)?;
471 match OpenOptions::new()
472 .write(true)
473 .create_new(true)
474 .open(&temporary)
475 {
476 Ok(file) => break (temporary, file),
477 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
478 Err(error) => return Err(error.into()),
479 }
480 };
481
482 let result = (|| {
483 let buffered = BufWriter::new(FaultingWriter::new(file, fault));
484 let gzip = GzBuilder::new()
485 .mtime(0)
486 .operating_system(255)
487 .write(buffered, Compression::default());
488 let (gzip, uncompressed_bytes) = write_framed_with_magic(&entries, gzip, magic)?;
489 let buffered = gzip.finish()?;
490 let faulting = buffered
491 .into_inner()
492 .map_err(|error| EvidenceArchiveError::Io(error.into_error()))?;
493 let file = faulting.inner;
494 file.sync_all()?;
495 let compressed_bytes = file.metadata()?.len();
496 drop(file);
497 fs::rename(&temporary, destination)?;
498 sync_parent(destination);
499 Ok(EvidenceArchiveMetadata {
500 schema_version,
501 format: "framed+gzip",
502 file: "evidence.raw.gz",
503 files: entries.len(),
504 uncompressed_bytes,
505 compressed_bytes,
506 })
507 })();
508 if result.is_err() {
509 let _ = fs::remove_file(&temporary);
510 }
511 result
512}
513
514fn read_exact_or_truncated<R: Read>(
515 reader: &mut R,
516 buffer: &mut [u8],
517 part: &'static str,
518) -> Result<(), EvidenceArchiveError> {
519 reader
520 .read_exact(buffer)
521 .map_err(|error| match error.kind() {
522 io::ErrorKind::UnexpectedEof => EvidenceArchiveError::Truncated(part),
523 _ => EvidenceArchiveError::Io(error),
524 })
525}
526
527fn read_framed_entries<R: Read>(
528 reader: &mut R,
529) -> Result<Vec<EvidenceArchiveEntry>, EvidenceArchiveError> {
530 let mut entries = Vec::new();
531 let mut seen = BTreeSet::new();
532 let mut previous: Option<String> = None;
533 loop {
534 let mut encoded_size = [0; 4];
535 let first = reader.read(&mut encoded_size[..1])?;
536 if first == 0 {
537 break;
538 }
539 read_exact_or_truncated(reader, &mut encoded_size[1..], "header length")?;
540 let header_size = u32::from_be_bytes(encoded_size) as usize;
541 if header_size == 0 {
542 return Err(EvidenceArchiveError::InvalidHeader("empty header"));
543 }
544 if header_size > MAX_ENTRY_HEADER_BYTES {
545 return Err(EvidenceArchiveError::InvalidHeader("header is too large"));
546 }
547 let mut encoded_header = vec![0; header_size];
548 read_exact_or_truncated(reader, &mut encoded_header, "header")?;
549 if encoded_header.last() != Some(&b'\n') {
550 return Err(EvidenceArchiveError::InvalidHeader(
551 "header is not newline terminated",
552 ));
553 }
554 let header: EntryHeader =
555 serde_json::from_slice(&encoded_header[..encoded_header.len() - 1])?;
556 let mut canonical = serde_json::to_vec(&header)?;
557 canonical.push(b'\n');
558 if encoded_header != canonical {
559 return Err(EvidenceArchiveError::InvalidHeader(
560 "header is not canonical JSON",
561 ));
562 }
563 validate_archive_path(&header.path)?;
564 if let Some(previous) = &previous {
565 if previous == &header.path {
566 return Err(EvidenceArchiveError::DuplicatePath(header.path));
567 }
568 if previous > &header.path {
569 return Err(EvidenceArchiveError::UnsortedPath {
570 previous: previous.clone(),
571 current: header.path,
572 });
573 }
574 }
575 if !seen.insert(header.path.clone()) {
576 return Err(EvidenceArchiveError::DuplicatePath(header.path));
577 }
578 let payload_size =
579 usize::try_from(header.bytes).map_err(|_| EvidenceArchiveError::SizeOverflow)?;
580 let mut contents = vec![0; payload_size];
581 read_exact_or_truncated(reader, &mut contents, "payload")?;
582 previous = Some(header.path.clone());
583 entries.push(EvidenceArchiveEntry {
584 path: header.path,
585 contents,
586 });
587 }
588 if !seen.contains("manifest.json") {
589 return Err(EvidenceArchiveError::MissingManifest);
590 }
591 Ok(entries)
592}
593
594pub fn read_archive(path: &Path) -> Result<Vec<EvidenceArchiveEntry>, EvidenceArchiveError> {
595 let input = BufReader::new(File::open(path)?);
596 let mut decoder = GzDecoder::new(input);
597 let mut magic = vec![0; EVIDENCE_ARCHIVE_MAGIC.len()];
598 read_exact_or_truncated(&mut decoder, &mut magic, "magic")?;
599 schema_version_from_magic(&magic)?;
600 let entries = read_framed_entries(&mut decoder)?;
601 let paths = entries
602 .iter()
603 .map(|entry| entry.path.as_str())
604 .collect::<BTreeSet<_>>();
605 if !paths.contains("frontend.json") {
606 return Err(EvidenceArchiveError::MissingFrontend);
607 }
608 if !paths.contains("coverage-model.json") {
609 return Err(EvidenceArchiveError::MissingCoverageModel);
610 }
611 let mut input = decoder.into_inner();
612 let mut trailing = [0; 1];
613 if input.read(&mut trailing)? != 0 {
614 return Err(EvidenceArchiveError::TrailingCompressedData);
615 }
616 Ok(entries)
617}
618
619fn schema_version_from_magic(magic: &[u8]) -> Result<u32, EvidenceArchiveError> {
620 if magic == EVIDENCE_ARCHIVE_MAGIC.as_bytes() {
621 Ok(EVIDENCE_ARCHIVE_SCHEMA_VERSION)
622 } else {
623 Err(EvidenceArchiveError::InvalidMagic)
624 }
625}
626
627pub fn read_archive_schema_version(path: &Path) -> Result<u32, EvidenceArchiveError> {
628 let input = BufReader::new(File::open(path)?);
629 let mut decoder = GzDecoder::new(input);
630 let mut magic = vec![0; EVIDENCE_ARCHIVE_MAGIC.len()];
631 read_exact_or_truncated(&mut decoder, &mut magic, "magic")?;
632 schema_version_from_magic(&magic)
633}
634
635#[cfg(test)]
636mod tests {
637 use std::{
638 fs,
639 io::Write,
640 time::{SystemTime, UNIX_EPOCH},
641 };
642
643 use flate2::{Compression, GzBuilder, write::GzEncoder};
644
645 use super::*;
646
647 fn temporary_directory(label: &str) -> PathBuf {
648 let nonce = SystemTime::now()
649 .duration_since(UNIX_EPOCH)
650 .unwrap()
651 .as_nanos();
652 let path = std::env::temp_dir().join(format!(
653 "supercov-rust-{label}-{}-{nonce}",
654 std::process::id(),
655 ));
656 fs::create_dir_all(&path).unwrap();
657 path
658 }
659
660 fn entry(path: &str, contents: &[u8]) -> EvidenceArchiveEntry {
661 EvidenceArchiveEntry {
662 path: path.to_owned(),
663 contents: contents.to_vec(),
664 }
665 }
666
667 fn gzip(bytes: &[u8]) -> Vec<u8> {
668 let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
669 encoder.write_all(bytes).unwrap();
670 encoder.finish().unwrap()
671 }
672
673 fn frame(entries: &[EvidenceArchiveEntry]) -> Vec<u8> {
674 let (bytes, _) =
675 write_framed_with_magic(entries, Vec::new(), EVIDENCE_ARCHIVE_MAGIC).unwrap();
676 bytes
677 }
678
679 fn identity_entries() -> Vec<EvidenceArchiveEntry> {
680 vec![
681 entry("coverage-model.json", b"{}"),
682 entry("frontend.json", b"{}"),
683 entry("manifest.json", b"{}"),
684 ]
685 }
686
687 fn write_compressed(root: &Path, bytes: &[u8]) -> PathBuf {
688 let path = root.join("archive.gz");
689 fs::write(&path, gzip(bytes)).unwrap();
690 path
691 }
692
693 #[test]
694 fn round_trips_binary_evidence_in_canonical_unicode_order() {
695 let root = temporary_directory("archive-roundtrip");
696 let first = root.join("first.gz");
697 let second = root.join("second.gz");
698 let entries = vec![
699 entry("coverage-model.json", b"{}"),
700 entry("frontend.json", b"{}"),
701 entry("𐀀/result.bin", &[0, 255, b'\n']),
702 entry("manifest.json", br#"{"decisions":[]}"#),
703 entry("é/result.jsonl", b"{}\n"),
704 entry("\u{e000}/result.jsonl", b"private\n"),
705 entry("a/result.jsonl", b"{\"hit\":true}\n"),
706 ];
707 let metadata = write_archive(entries.clone(), &first).unwrap();
708 write_archive(entries, &second).unwrap();
709 assert_eq!(fs::read(&first).unwrap(), fs::read(&second).unwrap());
710 assert_eq!(metadata.schema_version, EVIDENCE_ARCHIVE_SCHEMA_VERSION);
711 assert_eq!(metadata.files, 7);
712 assert!(metadata.uncompressed_bytes > 0);
713 assert!(metadata.compressed_bytes > 0);
714 assert_eq!(
715 read_archive(&first).unwrap(),
716 vec![
717 entry("a/result.jsonl", b"{\"hit\":true}\n"),
718 entry("coverage-model.json", b"{}"),
719 entry("frontend.json", b"{}"),
720 entry("manifest.json", br#"{"decisions":[]}"#),
721 entry("é/result.jsonl", b"{}\n"),
722 entry("\u{e000}/result.jsonl", b"private\n"),
723 entry("𐀀/result.bin", &[0, 255, b'\n']),
724 ],
725 );
726 fs::remove_dir_all(root).unwrap();
727 }
728
729 #[test]
730 fn injected_enospc_removes_partial_archive_and_preserves_no_destination() {
731 let root = temporary_directory("archive-enospc");
732 let destination = root.join("evidence.raw.gz");
733 let error = write_archive_with_fault(
734 identity_entries(),
735 &destination,
736 EvidenceArchiveWriteFault::NoSpaceAfterBytes(8),
737 )
738 .unwrap_err();
739 assert!(matches!(
740 error,
741 EvidenceArchiveError::Io(ref source)
742 if source.kind() == io::ErrorKind::StorageFull
743 ));
744 assert!(!destination.exists());
745 assert!(fs::read_dir(&root).unwrap().next().is_none());
746 fs::remove_dir_all(root).unwrap();
747 }
748
749 #[test]
750 fn rejects_every_noncanonical_framing_boundary() {
751 let root = temporary_directory("archive-invalid");
752 let identities = identity_entries();
753 let valid = frame(&identities);
754 let cases: Vec<(&str, Vec<u8>)> = vec![
755 (
756 "invalid magic",
757 [b"WRONG\n".as_slice(), &valid[6..]].concat(),
758 ),
759 (
760 "truncated header length",
761 [valid.as_slice(), &[0, 0]].concat(),
762 ),
763 (
764 "truncated header",
765 [EVIDENCE_ARCHIVE_MAGIC.as_bytes(), &[0, 0, 0, 9], b"{}\n"].concat(),
766 ),
767 (
768 "noncanonical header",
769 [
770 EVIDENCE_ARCHIVE_MAGIC.as_bytes(),
771 &(36_u32.to_be_bytes()),
772 b"{ \"path\":\"manifest.json\",\"bytes\":0}\n",
773 ]
774 .concat(),
775 ),
776 (
777 "truncated payload",
778 [
779 EVIDENCE_ARCHIVE_MAGIC.as_bytes(),
780 &(35_u32.to_be_bytes()),
781 b"{\"path\":\"manifest.json\",\"bytes\":2}\n",
782 b"{",
783 ]
784 .concat(),
785 ),
786 (
787 "trailing decompressed data",
788 [valid.as_slice(), b"x"].concat(),
789 ),
790 ];
791 for (label, bytes) in cases {
792 let path = write_compressed(&root, &bytes);
793 assert!(read_archive(&path).is_err(), "{label}");
794 }
795
796 let duplicate = frame(&[
797 entry("coverage-model.json", b"{}"),
798 entry("frontend.json", b"{}"),
799 entry("manifest.json", b"{}"),
800 entry("manifest.json", b"{}"),
801 ]);
802 assert!(read_archive(&write_compressed(&root, &duplicate)).is_err());
803 let unsorted = frame(&[
804 entry("frontend.json", b"{}"),
805 entry("coverage-model.json", b"{}"),
806 entry("manifest.json", b"{}"),
807 ]);
808 assert!(read_archive(&write_compressed(&root, &unsorted)).is_err());
809 let missing = frame(&[entry("result.json", b"{}")]);
810 assert!(read_archive(&write_compressed(&root, &missing)).is_err());
811
812 let path = root.join("trailing-compressed.gz");
813 let mut compressed = gzip(&valid);
814 compressed.push(0);
815 fs::write(&path, compressed).unwrap();
816 assert!(matches!(
817 read_archive(&path),
818 Err(EvidenceArchiveError::TrailingCompressedData)
819 ));
820 fs::remove_dir_all(root).unwrap();
821 }
822
823 #[test]
824 fn rejects_unsafe_duplicate_and_missing_manifest_entries_without_debris() {
825 let root = temporary_directory("archive-safety");
826 let destination = root.join("evidence.raw.gz");
827 for invalid in ["", "/absolute", "../escape", "a/../escape", "a\\b"] {
828 assert!(write_archive(vec![entry(invalid, b"")], &destination).is_err());
829 }
830 assert!(
831 write_archive(
832 vec![entry("manifest.json", b"{}"), entry("manifest.json", b"{}")],
833 &destination,
834 )
835 .is_err(),
836 );
837 assert!(write_archive(vec![entry("result.json", b"{}")], &destination).is_err());
838 assert!(!destination.exists());
839 assert_eq!(fs::read_dir(&root).unwrap().count(), 0);
840 fs::remove_dir_all(root).unwrap();
841 }
842
843 #[test]
844 fn collects_only_regular_files_and_normalizes_host_separators() {
845 let root = temporary_directory("archive-sources");
846 let evidence = root.join("evidence");
847 fs::create_dir_all(evidence.join("worker")).unwrap();
848 fs::write(evidence.join("worker/result.jsonl"), b"{}\n").unwrap();
849 let manifest = root.join("manifest.json");
850 fs::write(&manifest, b"{}\n").unwrap();
851 let entries = collect_sources(&[
852 EvidenceArchiveSource::Directory {
853 directory: evidence,
854 prefix: Some("server".to_owned()),
855 },
856 EvidenceArchiveSource::File {
857 file: manifest,
858 path: "manifest.json".to_owned(),
859 },
860 ])
861 .unwrap();
862 assert_eq!(
863 entries
864 .iter()
865 .map(|entry| entry.path.as_str())
866 .collect::<Vec<_>>(),
867 ["manifest.json", "server/worker/result.jsonl"],
868 );
869 fs::remove_dir_all(root).unwrap();
870 }
871
872 #[cfg(unix)]
873 #[test]
874 fn rejects_symlinked_sources_instead_of_following_them() {
875 use std::os::unix::fs::symlink;
876
877 let root = temporary_directory("archive-links");
878 let evidence = root.join("evidence");
879 fs::create_dir_all(&evidence).unwrap();
880 fs::write(root.join("outside"), b"secret").unwrap();
881 symlink(root.join("outside"), evidence.join("linked")).unwrap();
882 assert!(matches!(
883 collect_sources(&[EvidenceArchiveSource::Directory {
884 directory: evidence,
885 prefix: None,
886 }]),
887 Err(EvidenceArchiveError::UnsupportedSource(_))
888 ));
889 fs::remove_dir_all(root).unwrap();
890 }
891
892 #[test]
893 fn deterministic_gzip_header_has_no_clock_or_host_identity() {
894 let mut first = Vec::new();
895 let mut second = Vec::new();
896 for destination in [&mut first, &mut second] {
897 let encoder = GzBuilder::new()
898 .mtime(0)
899 .operating_system(255)
900 .write(destination, Compression::default());
901 let (encoder, _) = write_framed_with_magic(
902 &[entry("manifest.json", b"{}")],
903 encoder,
904 EVIDENCE_ARCHIVE_MAGIC,
905 )
906 .unwrap();
907 encoder.finish().unwrap();
908 }
909 assert_eq!(first, second);
910 assert_eq!(&first[4..8], &[0, 0, 0, 0]);
911 assert_eq!(first[9], 255);
912 }
913
914 #[test]
915 fn sole_archive_requires_and_round_trips_language_identity() {
916 let root = temporary_directory("archive-identity");
917 let destination = root.join("evidence.raw.gz");
918 let entries = vec![
919 entry("coverage-model.json", b"{}"),
920 entry("frontend.json", b"{}"),
921 entry("manifest.json", b"{}"),
922 ];
923 let metadata = write_archive(entries.clone(), &destination).unwrap();
924 assert_eq!(metadata.schema_version, EVIDENCE_ARCHIVE_SCHEMA_VERSION);
925 assert_eq!(read_archive(&destination).unwrap(), entries);
926
927 let missing = root.join("missing.raw.gz");
928 assert!(matches!(
929 write_archive(vec![entry("manifest.json", b"{}")], &missing),
930 Err(EvidenceArchiveError::MissingFrontend)
931 ));
932 assert!(!missing.exists());
933 fs::remove_dir_all(root).unwrap();
934 }
935
936 #[test]
937 fn reader_rejects_all_truncations_oversized_headers_and_missing_identity() {
938 let root = temporary_directory("archive-corruption");
939 let entries = vec![
940 entry("coverage-model.json", br#"{"schemaVersion":1}"#),
941 entry("frontend.json", br#"{"protocolVersion":2}"#),
942 entry("manifest.json", br#"{"decisions":[]}"#),
943 ];
944 let framed = frame(&entries);
945 for end in 0..framed.len() {
946 let path = write_compressed(&root, &framed[..end]);
947 assert!(
948 read_archive(&path).is_err(),
949 "accepted truncation at byte {end}"
950 );
951 }
952 let complete = write_compressed(&root, &framed);
953 assert_eq!(read_archive(&complete).unwrap(), entries);
954
955 let oversized_header = [
956 EVIDENCE_ARCHIVE_MAGIC.as_bytes(),
957 &((MAX_ENTRY_HEADER_BYTES as u32 + 1).to_be_bytes()),
958 ]
959 .concat();
960 assert!(matches!(
961 read_archive(&write_compressed(&root, &oversized_header)),
962 Err(EvidenceArchiveError::InvalidHeader("header is too large"))
963 ));
964
965 for missing in ["coverage-model.json", "frontend.json", "manifest.json"] {
966 let incomplete = entries
967 .iter()
968 .filter(|entry| entry.path != missing)
969 .cloned()
970 .collect::<Vec<_>>();
971 assert!(
972 read_archive(&write_compressed(&root, &frame(&incomplete))).is_err(),
973 "accepted archive without {missing}"
974 );
975 }
976 fs::remove_dir_all(root).unwrap();
977 }
978}