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