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/// A disk filling mid-archive, injected by the test that proves no partial
163/// archive is ever published.
164#[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            // A bounded, reused buffer avoids retaining a whole test trace.
596            // 256 KiB also lets the decoder process long matches efficiently;
597            // io::copy's 8 KiB buffer was slower than retaining every payload.
598            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
626/// Retain only the requested payloads while validating the entire archive,
627/// including skipped payload lengths, framing, the gzip checksum and trailer.
628/// Assertion manifests need a few small entries, not every test's trace.
629pub(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}