nextest_runner/record/
portable.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Portable archive creation and reading for recorded runs.
5//!
6//! A portable archive packages a single recorded run into a self-contained zip
7//! file that can be shared and imported elsewhere.
8//!
9//! # Reading portable archives
10//!
11//! Use [`PortableArchive::open`] to open a portable archive for reading. The
12//! archive contains:
13//!
14//! - A manifest (`manifest.json`) with run metadata.
15//! - A run log (`run.log.zst`) with test events.
16//! - An inner store (`store.zip`) with metadata and test output.
17//!
18//! To read from the inner store, call [`PortableArchive::open_store`] to get a
19//! [`PortableStoreReader`] that implements [`StoreReader`](super::reader::StoreReader).
20
21use super::{
22    format::{
23        CARGO_METADATA_JSON_PATH, OutputDict, PORTABLE_ARCHIVE_FORMAT_VERSION,
24        PORTABLE_MANIFEST_FILE_NAME, PortableManifest, RECORD_OPTS_JSON_PATH, RERUN_INFO_JSON_PATH,
25        RUN_LOG_FILE_NAME, RerunInfo, STDERR_DICT_PATH, STDOUT_DICT_PATH, STORE_FORMAT_VERSION,
26        STORE_ZIP_FILE_NAME, TEST_LIST_JSON_PATH, has_zip_extension,
27    },
28    reader::{StoreReader, decompress_with_dict},
29    store::{RecordedRunInfo, RunFilesExist, StoreRunsDir},
30    summary::{RecordOpts, TestEventSummary, ZipStoreOutput},
31};
32use crate::{
33    errors::{PortableArchiveError, PortableArchiveReadError, RecordReadError},
34    user_config::elements::MAX_MAX_OUTPUT_SIZE,
35};
36use atomicwrites::{AtomicFile, OverwriteBehavior};
37use camino::{Utf8Path, Utf8PathBuf};
38use countio::Counter;
39use debug_ignore::DebugIgnore;
40use itertools::Either;
41use nextest_metadata::TestListSummary;
42use std::{
43    borrow::Cow,
44    fs::File,
45    io::{self, BufRead, BufReader, Cursor, Read, Write},
46};
47use zip::{
48    CompressionMethod, ZipArchive, ZipWriter, read::ZipFileSeek, result::ZipError,
49    write::SimpleFileOptions,
50};
51
52/// Result of writing a portable archive.
53#[derive(Debug)]
54pub struct PortableArchiveResult {
55    /// The path to the written archive.
56    pub path: Utf8PathBuf,
57    /// The total size of the archive in bytes.
58    pub size: u64,
59}
60
61/// Result of extracting a file from a portable archive.
62#[derive(Debug)]
63pub struct ExtractOuterFileResult {
64    /// The number of bytes written to the output file.
65    pub bytes_written: u64,
66    /// If the file size exceeded the limit threshold, contains the claimed size.
67    ///
68    /// This is informational only; the full file is always extracted regardless
69    /// of whether this is `Some`.
70    pub exceeded_limit: Option<u64>,
71}
72
73/// Writer to create a portable archive from a recorded run.
74#[derive(Debug)]
75pub struct PortableArchiveWriter<'a> {
76    run_info: &'a RecordedRunInfo,
77    run_dir: Utf8PathBuf,
78}
79
80impl<'a> PortableArchiveWriter<'a> {
81    /// Creates a new writer for the given run.
82    ///
83    /// Validates that the run directory exists and contains the required files.
84    pub fn new(
85        run_info: &'a RecordedRunInfo,
86        runs_dir: StoreRunsDir<'_>,
87    ) -> Result<Self, PortableArchiveError> {
88        let run_dir = runs_dir.run_dir(run_info.run_id);
89
90        if !run_dir.exists() {
91            return Err(PortableArchiveError::RunDirNotFound { path: run_dir });
92        }
93
94        let store_zip_path = run_dir.join(STORE_ZIP_FILE_NAME);
95        if !store_zip_path.exists() {
96            return Err(PortableArchiveError::RequiredFileMissing {
97                run_dir,
98                file_name: STORE_ZIP_FILE_NAME,
99            });
100        }
101
102        let run_log_path = run_dir.join(RUN_LOG_FILE_NAME);
103        if !run_log_path.exists() {
104            return Err(PortableArchiveError::RequiredFileMissing {
105                run_dir,
106                file_name: RUN_LOG_FILE_NAME,
107            });
108        }
109
110        Ok(Self { run_info, run_dir })
111    }
112
113    /// Returns the default filename for this archive.
114    ///
115    /// Format: `nextest-run-{run_id}.zip`
116    pub fn default_filename(&self) -> String {
117        format!("nextest-run-{}.zip", self.run_info.run_id)
118    }
119
120    /// Writes the portable archive to the given directory.
121    ///
122    /// The archive is written atomically using a temporary file and rename.
123    /// The filename will be the default filename (`nextest-run-{run_id}.zip`).
124    pub fn write_to_dir(
125        &self,
126        output_dir: &Utf8Path,
127    ) -> Result<PortableArchiveResult, PortableArchiveError> {
128        let output_path = output_dir.join(self.default_filename());
129        self.write_to_path(&output_path)
130    }
131
132    /// Writes the portable archive to the given path.
133    ///
134    /// The archive is written atomically using a temporary file and rename.
135    pub fn write_to_path(
136        &self,
137        output_path: &Utf8Path,
138    ) -> Result<PortableArchiveResult, PortableArchiveError> {
139        let atomic_file = AtomicFile::new(output_path, OverwriteBehavior::AllowOverwrite);
140
141        let final_size = atomic_file
142            .write(|temp_file| {
143                let counter = Counter::new(temp_file);
144                let mut zip_writer = ZipWriter::new(counter);
145
146                self.write_manifest(&mut zip_writer)?;
147                self.copy_file(&mut zip_writer, RUN_LOG_FILE_NAME)?;
148                self.copy_file(&mut zip_writer, STORE_ZIP_FILE_NAME)?;
149
150                let counter = zip_writer
151                    .finish()
152                    .map_err(PortableArchiveError::ZipFinalize)?;
153
154                // Prefer the actual file size from metadata since ZipWriter
155                // seeks and overwrites headers, causing the counter to
156                // overcount. Fall back to the counter value if metadata is
157                // unavailable.
158                let counter_bytes = counter.writer_bytes() as u64;
159                let file = counter.into_inner();
160                let size = file.metadata().map(|m| m.len()).unwrap_or(counter_bytes);
161
162                Ok(size)
163            })
164            .map_err(|err| match err {
165                atomicwrites::Error::Internal(source) => PortableArchiveError::AtomicWrite {
166                    path: output_path.to_owned(),
167                    source,
168                },
169                atomicwrites::Error::User(e) => e,
170            })?;
171
172        Ok(PortableArchiveResult {
173            path: output_path.to_owned(),
174            size: final_size,
175        })
176    }
177
178    /// Writes the manifest to the archive.
179    fn write_manifest<W: Write + io::Seek>(
180        &self,
181        zip_writer: &mut ZipWriter<W>,
182    ) -> Result<(), PortableArchiveError> {
183        let manifest = PortableManifest::new(self.run_info);
184        let manifest_json = serde_json::to_vec_pretty(&manifest)
185            .map_err(PortableArchiveError::SerializeManifest)?;
186
187        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
188
189        zip_writer
190            .start_file(PORTABLE_MANIFEST_FILE_NAME, options)
191            .map_err(|source| PortableArchiveError::ZipStartFile {
192                file_name: PORTABLE_MANIFEST_FILE_NAME,
193                source,
194            })?;
195
196        zip_writer
197            .write_all(&manifest_json)
198            .map_err(|source| PortableArchiveError::ZipWrite {
199                file_name: PORTABLE_MANIFEST_FILE_NAME,
200                source,
201            })?;
202
203        Ok(())
204    }
205
206    /// Copies a file from the run directory to the archive.
207    ///
208    /// The file is stored without additional compression since `run.log.zst`
209    /// and `store.zip` are already compressed.
210    fn copy_file<W: Write + io::Seek>(
211        &self,
212        zip_writer: &mut ZipWriter<W>,
213        file_name: &'static str,
214    ) -> Result<(), PortableArchiveError> {
215        let source_path = self.run_dir.join(file_name);
216        let mut file = File::open(&source_path)
217            .map_err(|source| PortableArchiveError::ReadFile { file_name, source })?;
218
219        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
220
221        zip_writer
222            .start_file(file_name, options)
223            .map_err(|source| PortableArchiveError::ZipStartFile { file_name, source })?;
224
225        io::copy(&mut file, zip_writer)
226            .map_err(|source| PortableArchiveError::ZipWrite { file_name, source })?;
227
228        Ok(())
229    }
230}
231
232// ---
233// Portable archive reading
234// ---
235
236/// Backing storage for an archive.
237///
238/// - `Left(File)`: Direct file-backed archive (normal case).
239/// - `Right(Cursor<Vec<u8>>)`: Memory-backed archive (unwrapped from a wrapper zip).
240type ArchiveReadStorage = Either<File, Cursor<Vec<u8>>>;
241
242/// A portable archive opened for reading.
243#[derive(Debug)]
244pub struct PortableArchive {
245    archive_path: Utf8PathBuf,
246    manifest: PortableManifest,
247    outer_archive: ZipArchive<ArchiveReadStorage>,
248}
249
250impl RunFilesExist for PortableArchive {
251    fn store_zip_exists(&self) -> bool {
252        self.outer_archive
253            .index_for_name(STORE_ZIP_FILE_NAME)
254            .is_some()
255    }
256
257    fn run_log_exists(&self) -> bool {
258        self.outer_archive
259            .index_for_name(RUN_LOG_FILE_NAME)
260            .is_some()
261    }
262}
263
264impl PortableArchive {
265    /// Opens a portable archive from a file path.
266    ///
267    /// Validates the format and store versions on open to fail fast if the
268    /// archive cannot be read by this version of nextest.
269    ///
270    /// This method also handles "wrapper" archives: if the archive does not
271    /// contain `manifest.json` but contains exactly one `.zip` file, that inner
272    /// file is treated as the nextest portable archive. This supports GitHub
273    /// Actions artifact downloads, which wrap archives in an outer zip.
274    pub fn open(path: &Utf8Path) -> Result<Self, PortableArchiveReadError> {
275        let file = File::open(path).map_err(|error| PortableArchiveReadError::OpenArchive {
276            path: path.to_owned(),
277            error,
278        })?;
279
280        let mut outer_archive = ZipArchive::new(Either::Left(file)).map_err(|error| {
281            PortableArchiveReadError::ReadArchive {
282                path: path.to_owned(),
283                error,
284            }
285        })?;
286
287        // Check if this is a direct nextest archive (has manifest.json).
288        if outer_archive
289            .index_for_name(PORTABLE_MANIFEST_FILE_NAME)
290            .is_some()
291        {
292            return Self::open_validated(path, outer_archive);
293        }
294
295        // No manifest.json found. Check if this is a wrapper archive containing
296        // exactly one .zip file. Filter out directory entries (names ending with
297        // '/' or '\').
298        let mut file_count = 0;
299        let mut zip_count = 0;
300        let mut zip_file: Option<String> = None;
301        for name in outer_archive.file_names() {
302            if name.ends_with('/') || name.ends_with('\\') {
303                // This is a directory entry, skip it.
304                continue;
305            }
306            file_count += 1;
307            if has_zip_extension(Utf8Path::new(name)) {
308                zip_count += 1;
309                if zip_count == 1 {
310                    zip_file = Some(name.to_owned());
311                }
312            }
313        }
314
315        if let Some(inner_name) = zip_file.filter(|_| file_count == 1 && zip_count == 1) {
316            // We only support reading up to the MAX_MAX_OUTPUT_SIZE cap. We'll
317            // see if anyone complains -- they have to have both a wrapper zip
318            // and to exceed the cap. (Probably worth extracting to a file on
319            // disk or something at that point.)
320            let inner_bytes = read_outer_file(&mut outer_archive, inner_name.into(), path)?;
321            let inner_archive =
322                ZipArchive::new(Either::Right(Cursor::new(inner_bytes))).map_err(|error| {
323                    PortableArchiveReadError::ReadArchive {
324                        path: path.to_owned(),
325                        error,
326                    }
327                })?;
328            Self::open_validated(path, inner_archive)
329        } else {
330            Err(PortableArchiveReadError::NotAWrapperArchive {
331                path: path.to_owned(),
332                file_count,
333                zip_count,
334            })
335        }
336    }
337
338    /// Opens and validates an archive that is known to contain `manifest.json`.
339    fn open_validated(
340        path: &Utf8Path,
341        mut outer_archive: ZipArchive<ArchiveReadStorage>,
342    ) -> Result<Self, PortableArchiveReadError> {
343        // Read and parse the manifest.
344        let manifest_bytes =
345            read_outer_file(&mut outer_archive, PORTABLE_MANIFEST_FILE_NAME.into(), path)?;
346        let manifest: PortableManifest =
347            serde_json::from_slice(&manifest_bytes).map_err(|error| {
348                PortableArchiveReadError::ParseManifest {
349                    path: path.to_owned(),
350                    error,
351                }
352            })?;
353
354        // Validate format version.
355        if let Err(incompatibility) = manifest
356            .format_version
357            .check_readable_by(PORTABLE_ARCHIVE_FORMAT_VERSION)
358        {
359            return Err(PortableArchiveReadError::UnsupportedFormatVersion {
360                path: path.to_owned(),
361                found: manifest.format_version,
362                supported: PORTABLE_ARCHIVE_FORMAT_VERSION,
363                incompatibility,
364            });
365        }
366
367        // Validate store format version.
368        let store_version = manifest.store_format_version();
369        if let Err(incompatibility) = store_version.check_readable_by(STORE_FORMAT_VERSION) {
370            return Err(PortableArchiveReadError::UnsupportedStoreFormatVersion {
371                path: path.to_owned(),
372                found: store_version,
373                supported: STORE_FORMAT_VERSION,
374                incompatibility,
375            });
376        }
377
378        Ok(Self {
379            archive_path: path.to_owned(),
380            manifest,
381            outer_archive,
382        })
383    }
384
385    /// Returns the path to the archive file.
386    pub fn archive_path(&self) -> &Utf8Path {
387        &self.archive_path
388    }
389
390    /// Returns run info extracted from the manifest.
391    pub fn run_info(&self) -> RecordedRunInfo {
392        self.manifest.run_info()
393    }
394
395    /// Reads the run log into memory and returns it as an owned struct.
396    ///
397    /// The returned [`PortableArchiveRunLog`] can be used to iterate over events
398    /// independently of this archive, avoiding borrow conflicts with
399    /// [`open_store`](Self::open_store).
400    pub fn read_run_log(&mut self) -> Result<PortableArchiveRunLog, PortableArchiveReadError> {
401        let run_log_bytes = read_outer_file(
402            &mut self.outer_archive,
403            RUN_LOG_FILE_NAME.into(),
404            &self.archive_path,
405        )?;
406        Ok(PortableArchiveRunLog {
407            archive_path: self.archive_path.clone(),
408            run_log_bytes,
409        })
410    }
411
412    /// Extracts a file from the outer archive to a path, streaming directly.
413    ///
414    /// This avoids loading the entire file into memory. The full file is always
415    /// extracted regardless of size.
416    ///
417    /// If `check_limit` is true, the result will indicate whether the file
418    /// exceeded [`MAX_MAX_OUTPUT_SIZE`]. This is informational only and does
419    /// not affect extraction.
420    pub fn extract_outer_file_to_path(
421        &mut self,
422        file_name: &'static str,
423        output_path: &Utf8Path,
424        check_limit: bool,
425    ) -> Result<ExtractOuterFileResult, PortableArchiveReadError> {
426        extract_outer_file_to_path(
427            &mut self.outer_archive,
428            file_name,
429            &self.archive_path,
430            output_path,
431            check_limit,
432        )
433    }
434
435    /// Opens the inner store.zip for reading.
436    ///
437    /// The returned reader borrows from this archive and implements [`StoreReader`].
438    pub fn open_store(&mut self) -> Result<PortableStoreReader<'_>, PortableArchiveReadError> {
439        // Use by_name_seek to get a seekable handle to store.zip.
440        let store_handle = self
441            .outer_archive
442            .by_name_seek(STORE_ZIP_FILE_NAME)
443            .map_err(|error| match error {
444                ZipError::FileNotFound => PortableArchiveReadError::MissingFile {
445                    path: self.archive_path.clone(),
446                    file_name: Cow::Borrowed(STORE_ZIP_FILE_NAME),
447                },
448                _ => PortableArchiveReadError::ReadArchive {
449                    path: self.archive_path.clone(),
450                    error,
451                },
452            })?;
453
454        let store_archive = ZipArchive::new(store_handle).map_err(|error| {
455            PortableArchiveReadError::ReadArchive {
456                path: self.archive_path.clone(),
457                error,
458            }
459        })?;
460
461        Ok(PortableStoreReader {
462            archive_path: &self.archive_path,
463            store_archive,
464            stdout_dict: None,
465            stderr_dict: None,
466        })
467    }
468}
469
470/// Reads a file from the outer archive into memory, with size limits.
471fn read_outer_file(
472    archive: &mut ZipArchive<ArchiveReadStorage>,
473    file_name: Cow<'static, str>,
474    archive_path: &Utf8Path,
475) -> Result<Vec<u8>, PortableArchiveReadError> {
476    let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
477    let file = archive.by_name(&file_name).map_err(|error| match error {
478        ZipError::FileNotFound => PortableArchiveReadError::MissingFile {
479            path: archive_path.to_owned(),
480            file_name: file_name.clone(),
481        },
482        _ => PortableArchiveReadError::ReadArchive {
483            path: archive_path.to_owned(),
484            error,
485        },
486    })?;
487
488    let claimed_size = file.size();
489    if claimed_size > limit {
490        return Err(PortableArchiveReadError::FileTooLarge {
491            path: archive_path.to_owned(),
492            file_name,
493            size: claimed_size,
494            limit,
495        });
496    }
497
498    let capacity = usize::try_from(claimed_size).unwrap_or(usize::MAX);
499    let mut contents = Vec::with_capacity(capacity);
500
501    file.take(limit)
502        .read_to_end(&mut contents)
503        .map_err(|error| PortableArchiveReadError::ReadArchive {
504            path: archive_path.to_owned(),
505            error: ZipError::Io(error),
506        })?;
507
508    Ok(contents)
509}
510
511/// Extracts a file from the outer archive to a path, streaming directly.
512fn extract_outer_file_to_path(
513    archive: &mut ZipArchive<ArchiveReadStorage>,
514    file_name: &'static str,
515    archive_path: &Utf8Path,
516    output_path: &Utf8Path,
517    check_limit: bool,
518) -> Result<ExtractOuterFileResult, PortableArchiveReadError> {
519    let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
520    let mut file = archive.by_name(file_name).map_err(|error| match error {
521        ZipError::FileNotFound => PortableArchiveReadError::MissingFile {
522            path: archive_path.to_owned(),
523            file_name: Cow::Borrowed(file_name),
524        },
525        _ => PortableArchiveReadError::ReadArchive {
526            path: archive_path.to_owned(),
527            error,
528        },
529    })?;
530
531    let claimed_size = file.size();
532    let exceeded_limit = if check_limit && claimed_size > limit {
533        Some(claimed_size)
534    } else {
535        None
536    };
537
538    let mut output_file =
539        File::create(output_path).map_err(|error| PortableArchiveReadError::ExtractFile {
540            archive_path: archive_path.to_owned(),
541            file_name,
542            output_path: output_path.to_owned(),
543            error,
544        })?;
545
546    let bytes_written = io::copy(&mut file, &mut output_file).map_err(|error| {
547        PortableArchiveReadError::ExtractFile {
548            archive_path: archive_path.to_owned(),
549            file_name,
550            output_path: output_path.to_owned(),
551            error,
552        }
553    })?;
554
555    Ok(ExtractOuterFileResult {
556        bytes_written,
557        exceeded_limit,
558    })
559}
560
561/// The run log from a portable archive, read into memory.
562///
563/// This struct owns the run log bytes and can create event iterators
564/// independently of the [`PortableArchive`] it came from.
565#[derive(Debug)]
566pub struct PortableArchiveRunLog {
567    archive_path: Utf8PathBuf,
568    run_log_bytes: Vec<u8>,
569}
570
571impl PortableArchiveRunLog {
572    /// Returns an iterator over events from the run log.
573    pub fn events(&self) -> Result<PortableArchiveEventIter<'_>, RecordReadError> {
574        // The run log is zstd-compressed JSON Lines. Use with_buffer since the
575        // data is already in memory (no need for Decoder's internal BufReader).
576        let decoder =
577            zstd::stream::Decoder::with_buffer(&self.run_log_bytes[..]).map_err(|error| {
578                RecordReadError::OpenRunLog {
579                    path: self.archive_path.join(RUN_LOG_FILE_NAME),
580                    error,
581                }
582            })?;
583        Ok(PortableArchiveEventIter {
584            // BufReader is still needed for read_line().
585            reader: DebugIgnore(BufReader::new(decoder)),
586            line_buf: String::new(),
587            line_number: 0,
588        })
589    }
590}
591
592/// Iterator over events from a portable archive's run log.
593#[derive(Debug)]
594pub struct PortableArchiveEventIter<'a> {
595    reader: DebugIgnore<BufReader<zstd::stream::Decoder<'static, &'a [u8]>>>,
596    line_buf: String,
597    line_number: usize,
598}
599
600impl Iterator for PortableArchiveEventIter<'_> {
601    type Item = Result<TestEventSummary<ZipStoreOutput>, RecordReadError>;
602
603    fn next(&mut self) -> Option<Self::Item> {
604        loop {
605            self.line_buf.clear();
606            self.line_number += 1;
607
608            match self.reader.read_line(&mut self.line_buf) {
609                Ok(0) => return None,
610                Ok(_) => {
611                    let trimmed = self.line_buf.trim();
612                    if trimmed.is_empty() {
613                        continue;
614                    }
615                    return Some(serde_json::from_str(trimmed).map_err(|error| {
616                        RecordReadError::ParseEvent {
617                            line_number: self.line_number,
618                            error,
619                        }
620                    }));
621                }
622                Err(error) => {
623                    return Some(Err(RecordReadError::ReadRunLog {
624                        line_number: self.line_number,
625                        error,
626                    }));
627                }
628            }
629        }
630    }
631}
632
633/// Reader for the inner store.zip within a portable archive.
634///
635/// Borrows from [`PortableArchive`] and implements [`StoreReader`].
636pub struct PortableStoreReader<'a> {
637    archive_path: &'a Utf8Path,
638    store_archive: ZipArchive<ZipFileSeek<'a, ArchiveReadStorage>>,
639    /// Cached stdout dictionary loaded from the archive.
640    stdout_dict: Option<Vec<u8>>,
641    /// Cached stderr dictionary loaded from the archive.
642    stderr_dict: Option<Vec<u8>>,
643}
644
645impl std::fmt::Debug for PortableStoreReader<'_> {
646    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647        f.debug_struct("PortableStoreReader")
648            .field("archive_path", &self.archive_path)
649            .field("stdout_dict", &self.stdout_dict.as_ref().map(|d| d.len()))
650            .field("stderr_dict", &self.stderr_dict.as_ref().map(|d| d.len()))
651            .finish_non_exhaustive()
652    }
653}
654
655impl PortableStoreReader<'_> {
656    /// Reads a file from the store archive as bytes, with size limit.
657    fn read_store_file(&mut self, file_name: &str) -> Result<Vec<u8>, RecordReadError> {
658        let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
659        let file = self.store_archive.by_name(file_name).map_err(|error| {
660            RecordReadError::ReadArchiveFile {
661                file_name: file_name.to_string(),
662                error,
663            }
664        })?;
665
666        let claimed_size = file.size();
667        if claimed_size > limit {
668            return Err(RecordReadError::FileTooLarge {
669                file_name: file_name.to_string(),
670                size: claimed_size,
671                limit,
672            });
673        }
674
675        let capacity = usize::try_from(claimed_size).unwrap_or(usize::MAX);
676        let mut contents = Vec::with_capacity(capacity);
677
678        file.take(limit)
679            .read_to_end(&mut contents)
680            .map_err(|error| RecordReadError::Decompress {
681                file_name: file_name.to_string(),
682                error,
683            })?;
684
685        let actual_size = contents.len() as u64;
686        if actual_size != claimed_size {
687            return Err(RecordReadError::SizeMismatch {
688                file_name: file_name.to_string(),
689                claimed_size,
690                actual_size,
691            });
692        }
693
694        Ok(contents)
695    }
696
697    /// Returns the dictionary bytes for the given output file name, if known.
698    fn get_dict_for_output(&self, file_name: &str) -> Option<&[u8]> {
699        match OutputDict::for_output_file_name(file_name) {
700            OutputDict::Stdout => Some(
701                self.stdout_dict
702                    .as_ref()
703                    .expect("load_dictionaries must be called first"),
704            ),
705            OutputDict::Stderr => Some(
706                self.stderr_dict
707                    .as_ref()
708                    .expect("load_dictionaries must be called first"),
709            ),
710            OutputDict::None => None,
711        }
712    }
713}
714
715impl StoreReader for PortableStoreReader<'_> {
716    fn read_cargo_metadata(&mut self) -> Result<String, RecordReadError> {
717        let bytes = self.read_store_file(CARGO_METADATA_JSON_PATH)?;
718        String::from_utf8(bytes).map_err(|e| RecordReadError::Decompress {
719            file_name: CARGO_METADATA_JSON_PATH.to_string(),
720            error: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
721        })
722    }
723
724    fn read_test_list(&mut self) -> Result<TestListSummary, RecordReadError> {
725        let bytes = self.read_store_file(TEST_LIST_JSON_PATH)?;
726        serde_json::from_slice(&bytes).map_err(|error| RecordReadError::DeserializeMetadata {
727            file_name: TEST_LIST_JSON_PATH.to_string(),
728            error,
729        })
730    }
731
732    fn read_record_opts(&mut self) -> Result<RecordOpts, RecordReadError> {
733        let bytes = self.read_store_file(RECORD_OPTS_JSON_PATH)?;
734        serde_json::from_slice(&bytes).map_err(|error| RecordReadError::DeserializeMetadata {
735            file_name: RECORD_OPTS_JSON_PATH.to_string(),
736            error,
737        })
738    }
739
740    fn read_rerun_info(&mut self) -> Result<Option<RerunInfo>, RecordReadError> {
741        match self.read_store_file(RERUN_INFO_JSON_PATH) {
742            Ok(bytes) => {
743                let info = serde_json::from_slice(&bytes).map_err(|error| {
744                    RecordReadError::DeserializeMetadata {
745                        file_name: RERUN_INFO_JSON_PATH.to_string(),
746                        error,
747                    }
748                })?;
749                Ok(Some(info))
750            }
751            Err(RecordReadError::ReadArchiveFile {
752                error: ZipError::FileNotFound,
753                ..
754            }) => {
755                // File doesn't exist; this is not a rerun.
756                Ok(None)
757            }
758            Err(e) => Err(e),
759        }
760    }
761
762    fn load_dictionaries(&mut self) -> Result<(), RecordReadError> {
763        self.stdout_dict = Some(self.read_store_file(STDOUT_DICT_PATH)?);
764        self.stderr_dict = Some(self.read_store_file(STDERR_DICT_PATH)?);
765        Ok(())
766    }
767
768    fn read_output(&mut self, file_name: &str) -> Result<Vec<u8>, RecordReadError> {
769        let path = format!("out/{file_name}");
770        let compressed = self.read_store_file(&path)?;
771        let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
772
773        let dict_bytes = self.get_dict_for_output(file_name).ok_or_else(|| {
774            RecordReadError::UnknownOutputType {
775                file_name: file_name.to_owned(),
776            }
777        })?;
778
779        decompress_with_dict(&compressed, dict_bytes, limit).map_err(|error| {
780            RecordReadError::Decompress {
781                file_name: path,
782                error,
783            }
784        })
785    }
786
787    fn extract_file_to_path(
788        &mut self,
789        store_path: &str,
790        output_path: &Utf8Path,
791    ) -> Result<u64, RecordReadError> {
792        let mut file = self.store_archive.by_name(store_path).map_err(|error| {
793            RecordReadError::ReadArchiveFile {
794                file_name: store_path.to_owned(),
795                error,
796            }
797        })?;
798
799        let mut output_file =
800            File::create(output_path).map_err(|error| RecordReadError::ExtractFile {
801                store_path: store_path.to_owned(),
802                output_path: output_path.to_owned(),
803                error,
804            })?;
805
806        io::copy(&mut file, &mut output_file).map_err(|error| RecordReadError::ExtractFile {
807            store_path: store_path.to_owned(),
808            output_path: output_path.to_owned(),
809            error,
810        })
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use crate::record::{
818        format::{PORTABLE_ARCHIVE_FORMAT_VERSION, STORE_FORMAT_VERSION},
819        store::{CompletedRunStats, RecordedRunStatus, RecordedSizes},
820    };
821    use camino_tempfile::Utf8TempDir;
822    use chrono::Local;
823    use quick_junit::ReportUuid;
824    use semver::Version;
825    use std::{collections::BTreeMap, io::Read};
826    use zip::ZipArchive;
827
828    fn create_test_run_dir(run_id: ReportUuid) -> (Utf8TempDir, Utf8PathBuf) {
829        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
830        let runs_dir = temp_dir.path().to_owned();
831        let run_dir = runs_dir.join(run_id.to_string());
832        std::fs::create_dir_all(&run_dir).expect("create run dir");
833
834        let store_path = run_dir.join(STORE_ZIP_FILE_NAME);
835        let store_file = File::create(&store_path).expect("create store.zip");
836        let mut zip_writer = ZipWriter::new(store_file);
837        zip_writer
838            .start_file("test.txt", SimpleFileOptions::default())
839            .expect("start file");
840        zip_writer
841            .write_all(b"test content")
842            .expect("write content");
843        zip_writer.finish().expect("finish zip");
844
845        let log_path = run_dir.join(RUN_LOG_FILE_NAME);
846        let log_file = File::create(&log_path).expect("create run.log.zst");
847        let mut encoder = zstd::stream::Encoder::new(log_file, 3).expect("create encoder");
848        encoder.write_all(b"test log content").expect("write log");
849        encoder.finish().expect("finish encoder");
850
851        (temp_dir, runs_dir)
852    }
853
854    fn create_test_run_info(run_id: ReportUuid) -> RecordedRunInfo {
855        let now = Local::now().fixed_offset();
856        RecordedRunInfo {
857            run_id,
858            store_format_version: STORE_FORMAT_VERSION,
859            nextest_version: Version::new(0, 9, 111),
860            started_at: now,
861            last_written_at: now,
862            duration_secs: Some(12.345),
863            cli_args: vec!["cargo".to_owned(), "nextest".to_owned(), "run".to_owned()],
864            build_scope_args: vec!["--workspace".to_owned()],
865            env_vars: BTreeMap::from([("CARGO_TERM_COLOR".to_owned(), "always".to_owned())]),
866            parent_run_id: None,
867            sizes: RecordedSizes::default(),
868            status: RecordedRunStatus::Completed(CompletedRunStats {
869                initial_run_count: 10,
870                passed: 9,
871                failed: 1,
872                exit_code: 100,
873            }),
874        }
875    }
876
877    #[test]
878    fn test_default_filename() {
879        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
880        let (_temp_dir, runs_dir) = create_test_run_dir(run_id);
881        let run_info = create_test_run_info(run_id);
882
883        let writer = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir))
884            .expect("create writer");
885
886        assert_eq!(
887            writer.default_filename(),
888            "nextest-run-550e8400-e29b-41d4-a716-446655440000.zip"
889        );
890    }
891
892    #[test]
893    fn test_write_portable_archive() {
894        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
895        let (_temp_dir, runs_dir) = create_test_run_dir(run_id);
896        let run_info = create_test_run_info(run_id);
897
898        let writer = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir))
899            .expect("create writer");
900
901        let output_dir = camino_tempfile::tempdir().expect("create output dir");
902
903        let result = writer
904            .write_to_dir(output_dir.path())
905            .expect("write archive");
906
907        assert!(result.path.exists());
908        assert!(result.size > 0);
909
910        // Verify that the reported size matches the actual file size on disk.
911        let actual_size = std::fs::metadata(&result.path)
912            .expect("get file metadata")
913            .len();
914        assert_eq!(
915            result.size, actual_size,
916            "reported size should match actual file size"
917        );
918
919        assert_eq!(
920            result.path.file_name(),
921            Some("nextest-run-550e8400-e29b-41d4-a716-446655440000.zip")
922        );
923
924        let archive_file = File::open(&result.path).expect("open archive");
925        let mut archive = ZipArchive::new(archive_file).expect("read archive");
926
927        assert_eq!(archive.len(), 3);
928
929        {
930            let mut manifest_file = archive
931                .by_name(PORTABLE_MANIFEST_FILE_NAME)
932                .expect("manifest");
933            let mut manifest_content = String::new();
934            manifest_file
935                .read_to_string(&mut manifest_content)
936                .expect("read manifest");
937            let manifest: PortableManifest =
938                serde_json::from_str(&manifest_content).expect("parse manifest");
939            assert_eq!(manifest.format_version, PORTABLE_ARCHIVE_FORMAT_VERSION);
940            assert_eq!(manifest.run.run_id, run_id);
941        }
942
943        {
944            let store_file = archive.by_name(STORE_ZIP_FILE_NAME).expect("store.zip");
945            assert!(store_file.size() > 0);
946        }
947
948        {
949            let log_file = archive.by_name(RUN_LOG_FILE_NAME).expect("run.log.zst");
950            assert!(log_file.size() > 0);
951        }
952    }
953
954    #[test]
955    fn test_missing_run_dir() {
956        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
957        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
958        let runs_dir = temp_dir.path().to_owned();
959        let run_info = create_test_run_info(run_id);
960
961        let result = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
962
963        assert!(matches!(
964            result,
965            Err(PortableArchiveError::RunDirNotFound { .. })
966        ));
967    }
968
969    #[test]
970    fn test_missing_store_zip() {
971        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
972        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
973        let runs_dir = temp_dir.path().to_owned();
974        let run_dir = runs_dir.join(run_id.to_string());
975        std::fs::create_dir_all(&run_dir).expect("create run dir");
976
977        let log_path = run_dir.join(RUN_LOG_FILE_NAME);
978        let log_file = File::create(&log_path).expect("create run.log.zst");
979        let mut encoder = zstd::stream::Encoder::new(log_file, 3).expect("create encoder");
980        encoder.write_all(b"test").expect("write");
981        encoder.finish().expect("finish");
982
983        let run_info = create_test_run_info(run_id);
984        let result = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
985
986        assert!(
987            matches!(
988                &result,
989                Err(PortableArchiveError::RequiredFileMissing { file_name, .. })
990                if *file_name == STORE_ZIP_FILE_NAME
991            ),
992            "expected RequiredFileMissing for store.zip, got {result:?}"
993        );
994    }
995
996    #[test]
997    fn test_missing_run_log() {
998        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
999        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
1000        let runs_dir = temp_dir.path().to_owned();
1001        let run_dir = runs_dir.join(run_id.to_string());
1002        std::fs::create_dir_all(&run_dir).expect("create run dir");
1003
1004        let store_path = run_dir.join(STORE_ZIP_FILE_NAME);
1005        let store_file = File::create(&store_path).expect("create store.zip");
1006        let mut zip_writer = ZipWriter::new(store_file);
1007        zip_writer
1008            .start_file("test.txt", SimpleFileOptions::default())
1009            .expect("start");
1010        zip_writer.write_all(b"test").expect("write");
1011        zip_writer.finish().expect("finish");
1012
1013        let run_info = create_test_run_info(run_id);
1014        let result = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
1015
1016        assert!(
1017            matches!(
1018                &result,
1019                Err(PortableArchiveError::RequiredFileMissing { file_name, .. })
1020                if *file_name == RUN_LOG_FILE_NAME
1021            ),
1022            "expected RequiredFileMissing for run.log.zst, got {result:?}"
1023        );
1024    }
1025}