Skip to main content

supercov_engine/
evidence_archive.rs

1//! Language-neutral, streaming implementation of the frozen evidence archive.
2//!
3//! The framing contract is the authority. In particular, the reader does not
4//! preserve historical permissiveness from the JavaScript implementation:
5//! paths must be canonical and sorted, headers must use the exact canonical
6//! JSON encoding, and a gzip member may not hide trailing data.
7
8use 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}