Skip to main content

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 recording packages a single recorded run into a self-contained zip
7//! file that can be shared and imported elsewhere.
8//!
9//! # Reading portable recordings
10//!
11//! Use [`PortableRecording::open`] to open a portable recording 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 [`PortableRecording::open_store`] to get a
19//! [`PortableStoreReader`] that implements [`StoreReader`](super::reader::StoreReader).
20
21use super::{
22    format::{
23        BUILD_SCOPE_JSON_PATH, CARGO_METADATA_JSON_PATH, OutputDict, PORTABLE_MANIFEST_FILE_NAME,
24        PORTABLE_RECORDING_FORMAT_VERSION, PortableManifest, RECORD_OPTS_JSON_PATH,
25        RERUN_INFO_JSON_PATH, RUN_LOG_FILE_NAME, RecordedBuildScope, RerunInfo, STDERR_DICT_PATH,
26        STDOUT_DICT_PATH, STORE_FORMAT_VERSION, STORE_ZIP_FILE_NAME, TEST_LIST_JSON_PATH,
27        has_zip_extension, stored_file_options,
28    },
29    reader::{RunLogEventIter, StoreReader, decompress_with_dict},
30    store::{RecordedRunInfo, RunFilesExist, StoreRunsDir},
31    summary::RecordOpts,
32};
33use crate::{
34    errors::{PortableRecordingError, PortableRecordingReadError, RecordReadError},
35    user_config::elements::MAX_MAX_OUTPUT_SIZE,
36};
37use atomicwrites::{AtomicFile, OverwriteBehavior};
38use bytesize::ByteSize;
39use camino::{Utf8Path, Utf8PathBuf};
40use countio::Counter;
41use eazip::{Archive, ArchiveWriter, CompressionMethod};
42use itertools::Either;
43use nextest_metadata::TestListSummary;
44use std::{
45    borrow::Cow,
46    fs::File,
47    io::{self, BufReader, Cursor, Read, Seek, SeekFrom, Write},
48};
49
50/// Result of writing a portable recording.
51#[derive(Debug)]
52pub struct PortableRecordingResult {
53    /// The path to the written archive.
54    pub path: Utf8PathBuf,
55    /// The total size of the archive in bytes.
56    pub size: u64,
57}
58
59/// Result of extracting a file from a portable recording.
60#[derive(Debug)]
61pub struct ExtractOuterFileResult {
62    /// The number of bytes written to the output file.
63    pub bytes_written: u64,
64    /// If the file size exceeded the limit threshold, contains the claimed size.
65    ///
66    /// This is informational only; the full file is always extracted regardless
67    /// of whether this is `Some`.
68    pub exceeded_limit: Option<u64>,
69}
70
71/// Writer to create a portable recording from a recorded run.
72#[derive(Debug)]
73pub struct PortableRecordingWriter<'a> {
74    run_info: &'a RecordedRunInfo,
75    run_dir: Utf8PathBuf,
76}
77
78impl<'a> PortableRecordingWriter<'a> {
79    /// Creates a new writer for the given run.
80    ///
81    /// Validates that the run directory exists and contains the required files.
82    pub fn new(
83        run_info: &'a RecordedRunInfo,
84        runs_dir: StoreRunsDir<'_>,
85    ) -> Result<Self, PortableRecordingError> {
86        let run_dir = runs_dir.run_dir(run_info.run_id);
87
88        if !run_dir.exists() {
89            return Err(PortableRecordingError::RunDirNotFound { path: run_dir });
90        }
91
92        let store_zip_path = run_dir.join(STORE_ZIP_FILE_NAME);
93        if !store_zip_path.exists() {
94            return Err(PortableRecordingError::RequiredFileMissing {
95                run_dir,
96                file_name: STORE_ZIP_FILE_NAME,
97            });
98        }
99
100        let run_log_path = run_dir.join(RUN_LOG_FILE_NAME);
101        if !run_log_path.exists() {
102            return Err(PortableRecordingError::RequiredFileMissing {
103                run_dir,
104                file_name: RUN_LOG_FILE_NAME,
105            });
106        }
107
108        Ok(Self { run_info, run_dir })
109    }
110
111    /// Returns the default filename for this archive.
112    ///
113    /// Format: `nextest-run-{run_id}.zip`
114    pub fn default_filename(&self) -> String {
115        format!("nextest-run-{}.zip", self.run_info.run_id)
116    }
117
118    /// Writes the portable recording to the given directory.
119    ///
120    /// The archive is written atomically using a temporary file and rename.
121    /// The filename will be the default filename (`nextest-run-{run_id}.zip`).
122    pub fn write_to_dir(
123        &self,
124        output_dir: &Utf8Path,
125    ) -> Result<PortableRecordingResult, PortableRecordingError> {
126        let output_path = output_dir.join(self.default_filename());
127        self.write_to_path(&output_path)
128    }
129
130    /// Writes the portable recording to the given path.
131    ///
132    /// The archive is written atomically using a temporary file and rename.
133    pub fn write_to_path(
134        &self,
135        output_path: &Utf8Path,
136    ) -> Result<PortableRecordingResult, PortableRecordingError> {
137        let atomic_file = AtomicFile::new(output_path, OverwriteBehavior::AllowOverwrite);
138
139        let final_size = atomic_file
140            .write(|temp_file| {
141                let counter = Counter::new(temp_file);
142                let mut zip_writer = ArchiveWriter::new(counter);
143
144                self.write_manifest(&mut zip_writer)?;
145                self.copy_file(&mut zip_writer, RUN_LOG_FILE_NAME)?;
146                self.copy_file(&mut zip_writer, STORE_ZIP_FILE_NAME)?;
147
148                let counter = zip_writer
149                    .finish()
150                    .map_err(PortableRecordingError::ZipFinalize)?;
151
152                // Prefer the actual file size from metadata since ArchiveWriter
153                // writes data descriptors after entries, causing the counter to
154                // slightly overcount. Fall back to the counter value if metadata
155                // is unavailable.
156                let counter_bytes = counter.writer_bytes() as u64;
157                let file = counter.into_inner();
158                let size = file.metadata().map(|m| m.len()).unwrap_or(counter_bytes);
159
160                Ok(size)
161            })
162            .map_err(|err| match err {
163                atomicwrites::Error::Internal(source) => PortableRecordingError::AtomicWrite {
164                    path: output_path.to_owned(),
165                    source,
166                },
167                atomicwrites::Error::User(e) => e,
168            })?;
169
170        Ok(PortableRecordingResult {
171            path: output_path.to_owned(),
172            size: final_size,
173        })
174    }
175
176    /// Writes the manifest to the archive.
177    fn write_manifest<W: Write>(
178        &self,
179        zip_writer: &mut ArchiveWriter<W>,
180    ) -> Result<(), PortableRecordingError> {
181        let manifest = PortableManifest::new(self.run_info);
182        let manifest_json = serde_json::to_vec_pretty(&manifest)
183            .map_err(PortableRecordingError::SerializeManifest)?;
184
185        let options = stored_file_options();
186
187        zip_writer
188            .add_file(PORTABLE_MANIFEST_FILE_NAME, &manifest_json[..], &options)
189            .map_err(|source| PortableRecordingError::ZipWrite {
190                file_name: PORTABLE_MANIFEST_FILE_NAME,
191                source,
192            })?;
193
194        Ok(())
195    }
196
197    /// Copies a file from the run directory to the archive.
198    ///
199    /// The file is stored without additional compression since `run.log.zst`
200    /// and `store.zip` are already compressed.
201    fn copy_file<W: Write>(
202        &self,
203        zip_writer: &mut ArchiveWriter<W>,
204        file_name: &'static str,
205    ) -> Result<(), PortableRecordingError> {
206        let source_path = self.run_dir.join(file_name);
207        let mut file = File::open(&source_path)
208            .map_err(|source| PortableRecordingError::ReadFile { file_name, source })?;
209
210        let options = stored_file_options();
211
212        let mut streamer = zip_writer
213            .stream_file(file_name, &options)
214            .map_err(|source| PortableRecordingError::ZipStartFile { file_name, source })?;
215
216        io::copy(&mut file, &mut streamer)
217            .map_err(|source| PortableRecordingError::ZipWrite { file_name, source })?;
218
219        streamer
220            .finish()
221            .map_err(|source| PortableRecordingError::ZipWrite { file_name, source })?;
222
223        Ok(())
224    }
225}
226
227// ---
228// Portable recording reading
229// ---
230
231/// Maximum size for spooling a non-seekable input to a temporary file (4 GiB).
232///
233/// This is a safety limit to avoid filling up disk when reading from a pipe.
234/// Portable recordings are typically small (a few hundred MB at most), so this
235/// is generous.
236const SPOOL_SIZE_LIMIT: ByteSize = ByteSize(4 * 1024 * 1024 * 1024);
237
238/// Classifies a Windows file handle for seekability.
239///
240/// On Windows, `SetFilePointerEx` can spuriously succeed on named pipe handles
241/// (returning meaningless position values), so seek-based probing is
242/// unreliable. We use `GetFileType` instead, which definitively classifies the
243/// handle.
244#[cfg(windows)]
245enum WindowsFileKind {
246    /// A regular disk file (seekable).
247    Disk,
248    /// A pipe, FIFO, or socket (not seekable, must be spooled).
249    Pipe,
250    /// A character device or unknown handle type (not expected for recording
251    /// files).
252    Other(u32),
253}
254
255/// Classifies a Windows file handle using `GetFileType`.
256#[cfg(windows)]
257fn classify_windows_handle(file: &File) -> WindowsFileKind {
258    use std::os::windows::io::AsRawHandle;
259    use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
260
261    // SAFETY: the handle is valid because `file` is a live `File`.
262    let file_type = unsafe { GetFileType(file.as_raw_handle()) };
263    match file_type {
264        FILE_TYPE_DISK => WindowsFileKind::Disk,
265        FILE_TYPE_PIPE => WindowsFileKind::Pipe,
266        other => WindowsFileKind::Other(other),
267    }
268}
269
270/// Returns true if the I/O error indicates that the file descriptor does not
271/// support seeking (i.e. it is a pipe, FIFO, or socket).
272#[cfg(unix)]
273fn is_not_seekable_error(e: &io::Error) -> bool {
274    // Pipes/FIFOs/sockets fail lseek with ESPIPE.
275    e.raw_os_error() == Some(libc::ESPIPE)
276}
277
278/// Ensures that a file is seekable, spooling to a temp file if necessary.
279///
280/// Process substitution paths (e.g. `/proc/self/fd/11` from `<(curl url)`)
281/// produce pipe fds that are not seekable. ZIP reading requires seeking, so we
282/// spool the pipe contents to an anonymous temporary file first.
283///
284/// Returns the original file if it's already seekable, or a new temp file
285/// containing the spooled data.
286fn ensure_seekable(file: File, path: &Utf8Path) -> Result<File, PortableRecordingReadError> {
287    ensure_seekable_impl(file, path, SPOOL_SIZE_LIMIT)
288}
289
290/// Inner implementation of [`ensure_seekable`] with a configurable size limit.
291///
292/// Separated so tests can exercise the limit enforcement without writing 4 GiB.
293fn ensure_seekable_impl(
294    file: File,
295    path: &Utf8Path,
296    spool_limit: ByteSize,
297) -> Result<File, PortableRecordingReadError> {
298    // On Unix, lseek reliably fails with ESPIPE on pipes/FIFOs/sockets, so
299    // a seek probe is sufficient.
300    #[cfg(unix)]
301    {
302        let mut file = file;
303        match file.stream_position() {
304            Ok(_) => Ok(file),
305            Err(e) if is_not_seekable_error(&e) => spool_to_temp(file, path, spool_limit),
306            Err(e) => {
307                // Unexpected seek error (e.g. EBADF, EIO): propagate rather than
308                // silently falling into the spool path.
309                Err(PortableRecordingReadError::SeekProbe {
310                    path: path.to_owned(),
311                    error: e,
312                })
313            }
314        }
315    }
316
317    // On Windows, SetFilePointerEx can spuriously succeed on named pipe
318    // handles, so seek-based probing is unreliable. Use GetFileType to
319    // definitively classify the handle.
320    #[cfg(windows)]
321    match classify_windows_handle(&file) {
322        WindowsFileKind::Disk => Ok(file),
323        WindowsFileKind::Pipe => spool_to_temp(file, path, spool_limit),
324        WindowsFileKind::Other(file_type) => Err(PortableRecordingReadError::SeekProbe {
325            path: path.to_owned(),
326            error: io::Error::other(format!(
327                "unexpected file handle type {file_type:#x} (expected disk or pipe)"
328            )),
329        }),
330    }
331}
332
333/// Spools the contents of a non-seekable file to an anonymous temporary file.
334///
335/// Returns the temp file, rewound to the beginning so callers can read it.
336fn spool_to_temp(
337    file: File,
338    path: &Utf8Path,
339    spool_limit: ByteSize,
340) -> Result<File, PortableRecordingReadError> {
341    let mut temp =
342        camino_tempfile::tempfile().map_err(|error| PortableRecordingReadError::SpoolTempFile {
343            path: path.to_owned(),
344            error,
345        })?;
346
347    // Read up to spool_limit + 1 bytes. If we get more than the limit, the
348    // input is too large. Use saturating_add to avoid wrapping if the limit
349    // is u64::MAX (not an issue in practice since the limit is 4 GiB).
350    let bytes_copied = io::copy(
351        &mut (&file).take(spool_limit.0.saturating_add(1)),
352        &mut temp,
353    )
354    .map_err(|error| PortableRecordingReadError::SpoolTempFile {
355        path: path.to_owned(),
356        error,
357    })?;
358
359    if bytes_copied > spool_limit.0 {
360        return Err(PortableRecordingReadError::SpoolTooLarge {
361            path: path.to_owned(),
362            limit: spool_limit,
363        });
364    }
365
366    // Rewind so the archive reader can read from the beginning.
367    temp.seek(SeekFrom::Start(0))
368        .map_err(|error| PortableRecordingReadError::SpoolTempFile {
369            path: path.to_owned(),
370            error,
371        })?;
372
373    Ok(temp)
374}
375
376/// Backing storage for an archive.
377///
378/// - `Left(File)`: Direct file-backed archive (normal case).
379/// - `Right(Cursor<Vec<u8>>)`: Memory-backed archive (unwrapped from a wrapper zip).
380type ArchiveReadStorage = Either<File, Cursor<Vec<u8>>>;
381
382/// A portable recording opened for reading.
383pub struct PortableRecording {
384    archive_path: Utf8PathBuf,
385    manifest: PortableManifest,
386    outer_archive: Archive<BufReader<ArchiveReadStorage>>,
387}
388
389impl std::fmt::Debug for PortableRecording {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        f.debug_struct("PortableRecording")
392            .field("archive_path", &self.archive_path)
393            .field("manifest", &self.manifest)
394            .finish_non_exhaustive()
395    }
396}
397
398impl RunFilesExist for PortableRecording {
399    fn store_zip_exists(&self) -> bool {
400        self.outer_archive.index_of(STORE_ZIP_FILE_NAME).is_some()
401    }
402
403    fn run_log_exists(&self) -> bool {
404        self.outer_archive.index_of(RUN_LOG_FILE_NAME).is_some()
405    }
406}
407
408impl PortableRecording {
409    /// Opens a portable recording from a file path.
410    ///
411    /// Validates the format and store versions on open to fail fast if the
412    /// archive cannot be read by this version of nextest.
413    ///
414    /// This method also handles "wrapper" archives: if the archive does not
415    /// contain `manifest.json` but contains exactly one `.zip` file, that inner
416    /// file is treated as the nextest portable recording. This supports GitHub
417    /// Actions artifact downloads, which wrap archives in an outer zip.
418    pub fn open(path: &Utf8Path) -> Result<Self, PortableRecordingReadError> {
419        let file = File::open(path).map_err(|error| PortableRecordingReadError::OpenArchive {
420            path: path.to_owned(),
421            error,
422        })?;
423
424        // Ensure the file is seekable. Process substitution paths (e.g.
425        // `/proc/self/fd/11`) produce pipe fds; spool to a temp file if needed.
426        let file = ensure_seekable(file, path)?;
427
428        let mut outer_archive =
429            Archive::new(BufReader::new(Either::Left(file))).map_err(|error| {
430                PortableRecordingReadError::ReadArchive {
431                    path: path.to_owned(),
432                    error,
433                }
434            })?;
435
436        // Check if this is a direct nextest archive (has manifest.json).
437        if outer_archive
438            .index_of(PORTABLE_MANIFEST_FILE_NAME)
439            .is_some()
440        {
441            return Self::open_validated(path, outer_archive);
442        }
443
444        // No manifest.json found. Check if this is a wrapper archive containing
445        // exactly one .zip file. Filter out directory entries (names ending with
446        // '/' or '\').
447        let mut file_count = 0;
448        let mut zip_count = 0;
449        let mut zip_file: Option<String> = None;
450        for metadata in outer_archive.entries() {
451            let name = metadata.name();
452            if name.ends_with('/') || name.ends_with('\\') {
453                // This is a directory entry, skip it.
454                continue;
455            }
456            file_count += 1;
457            if has_zip_extension(Utf8Path::new(name)) {
458                zip_count += 1;
459                if zip_count == 1 {
460                    zip_file = Some(name.to_owned());
461                }
462            }
463        }
464
465        if let Some(inner_name) = zip_file.filter(|_| file_count == 1 && zip_count == 1) {
466            // We only support reading up to the MAX_MAX_OUTPUT_SIZE cap. We'll
467            // see if anyone complains -- they have to have both a wrapper zip
468            // and to exceed the cap. (Probably worth extracting to a file on
469            // disk or something at that point.)
470            let inner_bytes = read_outer_file(&mut outer_archive, inner_name.into(), path)?;
471            let inner_archive = Archive::new(BufReader::new(Either::Right(Cursor::new(
472                inner_bytes,
473            ))))
474            .map_err(|error| PortableRecordingReadError::ReadArchive {
475                path: path.to_owned(),
476                error,
477            })?;
478            Self::open_validated(path, inner_archive)
479        } else {
480            Err(PortableRecordingReadError::NotAWrapperArchive {
481                path: path.to_owned(),
482                file_count,
483                zip_count,
484            })
485        }
486    }
487
488    /// Opens and validates an archive that is known to contain `manifest.json`.
489    fn open_validated(
490        path: &Utf8Path,
491        mut outer_archive: Archive<BufReader<ArchiveReadStorage>>,
492    ) -> Result<Self, PortableRecordingReadError> {
493        // Read and parse the manifest.
494        let manifest_bytes =
495            read_outer_file(&mut outer_archive, PORTABLE_MANIFEST_FILE_NAME.into(), path)?;
496        let manifest: PortableManifest =
497            serde_json::from_slice(&manifest_bytes).map_err(|error| {
498                PortableRecordingReadError::ParseManifest {
499                    path: path.to_owned(),
500                    error,
501                }
502            })?;
503
504        // Validate format version.
505        if let Err(incompatibility) = manifest
506            .format_version
507            .check_readable_by(PORTABLE_RECORDING_FORMAT_VERSION)
508        {
509            return Err(PortableRecordingReadError::UnsupportedFormatVersion {
510                path: path.to_owned(),
511                found: manifest.format_version,
512                supported: PORTABLE_RECORDING_FORMAT_VERSION,
513                incompatibility,
514            });
515        }
516
517        // Validate store format version.
518        let store_version = manifest.store_format_version();
519        if let Err(incompatibility) = store_version.check_readable_by(STORE_FORMAT_VERSION) {
520            return Err(PortableRecordingReadError::UnsupportedStoreFormatVersion {
521                path: path.to_owned(),
522                found: store_version,
523                supported: STORE_FORMAT_VERSION,
524                incompatibility,
525            });
526        }
527
528        Ok(Self {
529            archive_path: path.to_owned(),
530            manifest,
531            outer_archive,
532        })
533    }
534
535    /// Returns the path to the archive file.
536    pub fn archive_path(&self) -> &Utf8Path {
537        &self.archive_path
538    }
539
540    /// Returns run info extracted from the manifest.
541    pub fn run_info(&self) -> RecordedRunInfo {
542        self.manifest.run_info()
543    }
544
545    /// Reads the run log into memory and returns it as an owned struct.
546    ///
547    /// The returned [`PortableRecordingRunLog`] can be used to iterate over events
548    /// independently of this archive, avoiding borrow conflicts with
549    /// [`open_store`](Self::open_store).
550    pub fn read_run_log(&mut self) -> Result<PortableRecordingRunLog, PortableRecordingReadError> {
551        let run_log_bytes = read_outer_file(
552            &mut self.outer_archive,
553            RUN_LOG_FILE_NAME.into(),
554            &self.archive_path,
555        )?;
556        Ok(PortableRecordingRunLog {
557            archive_path: self.archive_path.clone(),
558            run_log_bytes,
559        })
560    }
561
562    /// Extracts a file from the outer archive to a path, streaming directly.
563    ///
564    /// This avoids loading the entire file into memory. The full file is always
565    /// extracted regardless of size.
566    ///
567    /// If `check_limit` is true, the result will indicate whether the file
568    /// exceeded [`MAX_MAX_OUTPUT_SIZE`]. This is informational only and does
569    /// not affect extraction.
570    pub fn extract_outer_file_to_path(
571        &mut self,
572        file_name: &'static str,
573        output_path: &Utf8Path,
574        check_limit: bool,
575    ) -> Result<ExtractOuterFileResult, PortableRecordingReadError> {
576        extract_outer_file_to_path(
577            &mut self.outer_archive,
578            file_name,
579            &self.archive_path,
580            output_path,
581            check_limit,
582        )
583    }
584
585    /// Opens the inner store.zip for reading.
586    ///
587    /// The returned reader borrows from this archive via a zero-copy
588    /// `Take` window into the outer archive's reader. CRC verification of
589    /// the outer store.zip entry is skipped because each inner entry has its
590    /// own CRC check.
591    pub fn open_store(&mut self) -> Result<PortableStoreReader<'_>, PortableRecordingReadError> {
592        let file = self
593            .outer_archive
594            .get_by_name(STORE_ZIP_FILE_NAME)
595            .ok_or_else(|| PortableRecordingReadError::MissingFile {
596                path: self.archive_path.clone(),
597                file_name: Cow::Borrowed(STORE_ZIP_FILE_NAME),
598            })?;
599
600        let compression = file.metadata().compression_method;
601        if compression != CompressionMethod::STORE {
602            return Err(PortableRecordingReadError::CompressedInnerArchive {
603                archive_path: self.archive_path.clone(),
604                compression,
605            });
606        }
607
608        // read_stored() provides a Take-bounded reader for stored files.
609        let raw = file
610            .read_stored()
611            .map_err(|error| PortableRecordingReadError::ReadArchive {
612                path: self.archive_path.clone(),
613                error,
614            })?;
615
616        let store_archive =
617            Archive::new(raw).map_err(|error| PortableRecordingReadError::ReadArchive {
618                path: self.archive_path.clone(),
619                error,
620            })?;
621
622        Ok(PortableStoreReader {
623            archive_path: &self.archive_path,
624            store_archive,
625            stdout_dict: None,
626            stderr_dict: None,
627        })
628    }
629}
630
631/// Reads a file from the outer archive into memory, with size limits.
632fn read_outer_file(
633    archive: &mut Archive<BufReader<ArchiveReadStorage>>,
634    file_name: Cow<'static, str>,
635    archive_path: &Utf8Path,
636) -> Result<Vec<u8>, PortableRecordingReadError> {
637    let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
638    let mut file =
639        archive
640            .get_by_name(&file_name)
641            .ok_or_else(|| PortableRecordingReadError::MissingFile {
642                path: archive_path.to_owned(),
643                file_name: file_name.clone(),
644            })?;
645
646    let claimed_size = file.metadata().uncompressed_size;
647    if claimed_size > limit {
648        return Err(PortableRecordingReadError::FileTooLarge {
649            path: archive_path.to_owned(),
650            file_name,
651            size: claimed_size,
652            limit,
653        });
654    }
655
656    let capacity = usize::try_from(claimed_size).unwrap_or(usize::MAX);
657    let mut contents = Vec::with_capacity(capacity);
658
659    file.read()
660        .and_then(|reader| reader.take(limit).read_to_end(&mut contents))
661        .map_err(|error| PortableRecordingReadError::ReadArchive {
662            path: archive_path.to_owned(),
663            error,
664        })?;
665
666    Ok(contents)
667}
668
669/// Extracts a file from the outer archive to a path, streaming directly.
670fn extract_outer_file_to_path(
671    archive: &mut Archive<BufReader<ArchiveReadStorage>>,
672    file_name: &'static str,
673    archive_path: &Utf8Path,
674    output_path: &Utf8Path,
675    check_limit: bool,
676) -> Result<ExtractOuterFileResult, PortableRecordingReadError> {
677    let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
678    let mut file =
679        archive
680            .get_by_name(file_name)
681            .ok_or_else(|| PortableRecordingReadError::MissingFile {
682                path: archive_path.to_owned(),
683                file_name: Cow::Borrowed(file_name),
684            })?;
685
686    let claimed_size = file.metadata().uncompressed_size;
687    let exceeded_limit = if check_limit && claimed_size > limit {
688        Some(claimed_size)
689    } else {
690        None
691    };
692
693    let mut output_file =
694        File::create(output_path).map_err(|error| PortableRecordingReadError::ExtractFile {
695            archive_path: archive_path.to_owned(),
696            file_name,
697            output_path: output_path.to_owned(),
698            error,
699        })?;
700
701    let mut reader = file
702        .read()
703        .map_err(|error| PortableRecordingReadError::ReadArchive {
704            path: archive_path.to_owned(),
705            error,
706        })?;
707
708    let bytes_written = io::copy(&mut reader, &mut output_file).map_err(|error| {
709        PortableRecordingReadError::ExtractFile {
710            archive_path: archive_path.to_owned(),
711            file_name,
712            output_path: output_path.to_owned(),
713            error,
714        }
715    })?;
716
717    Ok(ExtractOuterFileResult {
718        bytes_written,
719        exceeded_limit,
720    })
721}
722
723/// The run log from a portable recording, read into memory.
724///
725/// This struct owns the run log bytes and can create event iterators
726/// independently of the [`PortableRecording`] it came from.
727#[derive(Debug)]
728pub struct PortableRecordingRunLog {
729    archive_path: Utf8PathBuf,
730    run_log_bytes: Vec<u8>,
731}
732
733impl PortableRecordingRunLog {
734    /// Returns an iterator over events from the run log.
735    pub fn events(
736        &self,
737    ) -> Result<RunLogEventIter<BufReader<zstd::stream::Decoder<'static, &[u8]>>>, RecordReadError>
738    {
739        // The run log is zstd-compressed JSON Lines. Use with_buffer since the
740        // data is already in memory (no need for Decoder's internal BufReader).
741        let decoder =
742            zstd::stream::Decoder::with_buffer(&self.run_log_bytes[..]).map_err(|error| {
743                RecordReadError::OpenRunLog {
744                    path: self.archive_path.join(RUN_LOG_FILE_NAME),
745                    error,
746                }
747            })?;
748        // BufReader is still needed for read_until().
749        Ok(RunLogEventIter::new(BufReader::new(decoder)))
750    }
751}
752
753/// Reader for the inner store.zip within a portable recording.
754///
755/// Borrows from [`PortableRecording`] via a zero-copy `Take` window into the
756/// outer archive's reader. Implements [`StoreReader`].
757pub struct PortableStoreReader<'a> {
758    archive_path: &'a Utf8Path,
759    store_archive: Archive<io::Take<&'a mut BufReader<ArchiveReadStorage>>>,
760    /// Cached stdout dictionary loaded from the archive.
761    stdout_dict: Option<Vec<u8>>,
762    /// Cached stderr dictionary loaded from the archive.
763    stderr_dict: Option<Vec<u8>>,
764}
765
766impl std::fmt::Debug for PortableStoreReader<'_> {
767    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
768        f.debug_struct("PortableStoreReader")
769            .field("archive_path", &self.archive_path)
770            .field("stdout_dict", &self.stdout_dict.as_ref().map(|d| d.len()))
771            .field("stderr_dict", &self.stderr_dict.as_ref().map(|d| d.len()))
772            .finish_non_exhaustive()
773    }
774}
775
776impl PortableStoreReader<'_> {
777    /// Reads a file from the store archive as bytes, with size limit.
778    fn read_store_file(&mut self, file_name: &str) -> Result<Vec<u8>, RecordReadError> {
779        let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
780        let mut file = self.store_archive.get_by_name(file_name).ok_or_else(|| {
781            RecordReadError::FileNotFound {
782                file_name: file_name.to_string(),
783            }
784        })?;
785
786        let claimed_size = file.metadata().uncompressed_size;
787        if claimed_size > limit {
788            return Err(RecordReadError::FileTooLarge {
789                file_name: file_name.to_string(),
790                size: claimed_size,
791                limit,
792            });
793        }
794
795        let capacity = usize::try_from(claimed_size).unwrap_or(usize::MAX);
796        let mut contents = Vec::with_capacity(capacity);
797
798        file.read()
799            .and_then(|reader| reader.take(limit).read_to_end(&mut contents))
800            .map_err(|error| RecordReadError::Decompress {
801                file_name: file_name.to_string(),
802                error,
803            })?;
804
805        let actual_size = contents.len() as u64;
806        if actual_size != claimed_size {
807            return Err(RecordReadError::SizeMismatch {
808                file_name: file_name.to_string(),
809                claimed_size,
810                actual_size,
811            });
812        }
813
814        Ok(contents)
815    }
816
817    /// Returns the dictionary bytes for the given output file name, if known.
818    fn get_dict_for_output(&self, file_name: &str) -> Option<&[u8]> {
819        match OutputDict::for_output_file_name(file_name) {
820            OutputDict::Stdout => Some(
821                self.stdout_dict
822                    .as_ref()
823                    .expect("load_dictionaries must be called first"),
824            ),
825            OutputDict::Stderr => Some(
826                self.stderr_dict
827                    .as_ref()
828                    .expect("load_dictionaries must be called first"),
829            ),
830            OutputDict::None => None,
831        }
832    }
833}
834
835impl StoreReader for PortableStoreReader<'_> {
836    fn read_cargo_metadata(&mut self) -> Result<String, RecordReadError> {
837        let bytes = self.read_store_file(CARGO_METADATA_JSON_PATH)?;
838        String::from_utf8(bytes).map_err(|e| RecordReadError::Decompress {
839            file_name: CARGO_METADATA_JSON_PATH.to_string(),
840            error: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
841        })
842    }
843
844    fn read_test_list(&mut self) -> Result<TestListSummary, RecordReadError> {
845        let bytes = self.read_store_file(TEST_LIST_JSON_PATH)?;
846        serde_json::from_slice(&bytes).map_err(|error| RecordReadError::DeserializeMetadata {
847            file_name: TEST_LIST_JSON_PATH.to_string(),
848            error,
849        })
850    }
851
852    fn read_record_opts(&mut self) -> Result<RecordOpts, RecordReadError> {
853        let bytes = self.read_store_file(RECORD_OPTS_JSON_PATH)?;
854        serde_json::from_slice(&bytes).map_err(|error| RecordReadError::DeserializeMetadata {
855            file_name: RECORD_OPTS_JSON_PATH.to_string(),
856            error,
857        })
858    }
859
860    fn read_build_scope(&mut self) -> Result<RecordedBuildScope, RecordReadError> {
861        let bytes = self.read_store_file(BUILD_SCOPE_JSON_PATH)?;
862        serde_json::from_slice(&bytes).map_err(|error| RecordReadError::DeserializeMetadata {
863            file_name: BUILD_SCOPE_JSON_PATH.to_string(),
864            error,
865        })
866    }
867
868    fn read_rerun_info(&mut self) -> Result<Option<RerunInfo>, RecordReadError> {
869        match self.read_store_file(RERUN_INFO_JSON_PATH) {
870            Ok(bytes) => {
871                let info = serde_json::from_slice(&bytes).map_err(|error| {
872                    RecordReadError::DeserializeMetadata {
873                        file_name: RERUN_INFO_JSON_PATH.to_string(),
874                        error,
875                    }
876                })?;
877                Ok(Some(info))
878            }
879            Err(RecordReadError::FileNotFound { .. }) => {
880                // File doesn't exist; this is not a rerun.
881                Ok(None)
882            }
883            Err(e) => Err(e),
884        }
885    }
886
887    fn load_dictionaries(&mut self) -> Result<(), RecordReadError> {
888        self.stdout_dict = Some(self.read_store_file(STDOUT_DICT_PATH)?);
889        self.stderr_dict = Some(self.read_store_file(STDERR_DICT_PATH)?);
890        Ok(())
891    }
892
893    fn read_output(&mut self, file_name: &str) -> Result<Vec<u8>, RecordReadError> {
894        let path = format!("out/{file_name}");
895        let compressed = self.read_store_file(&path)?;
896        let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
897
898        let dict_bytes = self.get_dict_for_output(file_name).ok_or_else(|| {
899            RecordReadError::UnknownOutputType {
900                file_name: file_name.to_owned(),
901            }
902        })?;
903
904        decompress_with_dict(&compressed, dict_bytes, limit).map_err(|error| {
905            RecordReadError::Decompress {
906                file_name: path,
907                error,
908            }
909        })
910    }
911
912    fn extract_file_to_path(
913        &mut self,
914        store_path: &str,
915        output_path: &Utf8Path,
916    ) -> Result<u64, RecordReadError> {
917        let mut file = self.store_archive.get_by_name(store_path).ok_or_else(|| {
918            RecordReadError::FileNotFound {
919                file_name: store_path.to_owned(),
920            }
921        })?;
922
923        let mut output_file =
924            File::create(output_path).map_err(|error| RecordReadError::ExtractFile {
925                store_path: store_path.to_owned(),
926                output_path: output_path.to_owned(),
927                error,
928            })?;
929
930        let mut reader = file
931            .read()
932            .map_err(|error| RecordReadError::ReadArchiveFile {
933                file_name: store_path.to_owned(),
934                error,
935            })?;
936
937        io::copy(&mut reader, &mut output_file).map_err(|error| RecordReadError::ExtractFile {
938            store_path: store_path.to_owned(),
939            output_path: output_path.to_owned(),
940            error,
941        })
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948    use crate::record::{
949        format::{PORTABLE_RECORDING_FORMAT_VERSION, STORE_FORMAT_VERSION},
950        store::{CompletedRunStats, RecordedRunStatus, RecordedSizes},
951    };
952    use camino_tempfile::{NamedUtf8TempFile, Utf8TempDir};
953    use chrono::Local;
954    use eazip::write::FileOptions;
955    use quick_junit::ReportUuid;
956    use semver::Version;
957    use std::{collections::BTreeMap, io::Read};
958
959    fn create_test_run_dir(run_id: ReportUuid) -> (Utf8TempDir, Utf8PathBuf) {
960        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
961        let runs_dir = temp_dir.path().to_owned();
962        let run_dir = runs_dir.join(run_id.to_string());
963        std::fs::create_dir_all(&run_dir).expect("create run dir");
964
965        let store_path = run_dir.join(STORE_ZIP_FILE_NAME);
966        let store_file = File::create(&store_path).expect("create store.zip");
967        let mut zip_writer = ArchiveWriter::new(store_file);
968        let options = FileOptions::default();
969        zip_writer
970            .add_file("test.txt", &b"test content"[..], &options)
971            .expect("add file");
972        zip_writer.finish().expect("finish zip");
973
974        let log_path = run_dir.join(RUN_LOG_FILE_NAME);
975        let log_file = File::create(&log_path).expect("create run.log.zst");
976        let mut encoder = zstd::stream::Encoder::new(log_file, 3).expect("create encoder");
977        encoder.write_all(b"test log content").expect("write log");
978        encoder.finish().expect("finish encoder");
979
980        (temp_dir, runs_dir)
981    }
982
983    fn create_test_run_info(run_id: ReportUuid) -> RecordedRunInfo {
984        let now = Local::now().fixed_offset();
985        RecordedRunInfo {
986            run_id,
987            store_format_version: STORE_FORMAT_VERSION,
988            nextest_version: Version::new(0, 9, 111),
989            started_at: now,
990            last_written_at: now,
991            duration_secs: Some(12.345),
992            cli_args: vec!["cargo".to_owned(), "nextest".to_owned(), "run".to_owned()],
993            env_vars: BTreeMap::from([("CARGO_TERM_COLOR".to_owned(), "always".to_owned())]),
994            parent_run_id: None,
995            sizes: RecordedSizes::default(),
996            status: RecordedRunStatus::Completed(CompletedRunStats {
997                initial_run_count: 10,
998                passed: 9,
999                failed: 1,
1000                exit_code: 100,
1001            }),
1002        }
1003    }
1004
1005    #[test]
1006    fn test_default_filename() {
1007        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
1008        let (_temp_dir, runs_dir) = create_test_run_dir(run_id);
1009        let run_info = create_test_run_info(run_id);
1010
1011        let writer = PortableRecordingWriter::new(&run_info, StoreRunsDir::new(&runs_dir))
1012            .expect("create writer");
1013
1014        assert_eq!(
1015            writer.default_filename(),
1016            "nextest-run-550e8400-e29b-41d4-a716-446655440000.zip"
1017        );
1018    }
1019
1020    #[test]
1021    fn test_write_portable_recording() {
1022        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
1023        let (_temp_dir, runs_dir) = create_test_run_dir(run_id);
1024        let run_info = create_test_run_info(run_id);
1025
1026        let writer = PortableRecordingWriter::new(&run_info, StoreRunsDir::new(&runs_dir))
1027            .expect("create writer");
1028
1029        let output_dir = camino_tempfile::tempdir().expect("create output dir");
1030
1031        let result = writer
1032            .write_to_dir(output_dir.path())
1033            .expect("write archive");
1034
1035        assert!(result.path.exists());
1036        assert!(result.size > 0);
1037
1038        // Verify that the reported size matches the actual file size on disk.
1039        let actual_size = std::fs::metadata(&result.path)
1040            .expect("get file metadata")
1041            .len();
1042        assert_eq!(
1043            result.size, actual_size,
1044            "reported size should match actual file size"
1045        );
1046
1047        assert_eq!(
1048            result.path.file_name(),
1049            Some("nextest-run-550e8400-e29b-41d4-a716-446655440000.zip")
1050        );
1051
1052        let archive_file = File::open(&result.path).expect("open archive");
1053        let mut archive = Archive::new(BufReader::new(archive_file)).expect("read archive");
1054
1055        assert_eq!(archive.entries().len(), 3);
1056
1057        {
1058            let mut manifest_file = archive
1059                .get_by_name(PORTABLE_MANIFEST_FILE_NAME)
1060                .expect("manifest");
1061            let mut manifest_content = String::new();
1062            manifest_file
1063                .read()
1064                .expect("get reader")
1065                .read_to_string(&mut manifest_content)
1066                .expect("read manifest");
1067            let manifest: PortableManifest =
1068                serde_json::from_str(&manifest_content).expect("parse manifest");
1069            assert_eq!(manifest.format_version, PORTABLE_RECORDING_FORMAT_VERSION);
1070            assert_eq!(manifest.run.run_id, run_id);
1071        }
1072
1073        {
1074            let store_file = archive.get_by_name(STORE_ZIP_FILE_NAME).expect("store.zip");
1075            assert!(store_file.metadata().uncompressed_size > 0);
1076        }
1077
1078        {
1079            let log_file = archive.get_by_name(RUN_LOG_FILE_NAME).expect("run.log.zst");
1080            assert!(log_file.metadata().uncompressed_size > 0);
1081        }
1082    }
1083
1084    #[test]
1085    fn test_missing_run_dir() {
1086        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
1087        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
1088        let runs_dir = temp_dir.path().to_owned();
1089        let run_info = create_test_run_info(run_id);
1090
1091        let result = PortableRecordingWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
1092
1093        assert!(matches!(
1094            result,
1095            Err(PortableRecordingError::RunDirNotFound { .. })
1096        ));
1097    }
1098
1099    #[test]
1100    fn test_missing_store_zip() {
1101        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
1102        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
1103        let runs_dir = temp_dir.path().to_owned();
1104        let run_dir = runs_dir.join(run_id.to_string());
1105        std::fs::create_dir_all(&run_dir).expect("create run dir");
1106
1107        let log_path = run_dir.join(RUN_LOG_FILE_NAME);
1108        let log_file = File::create(&log_path).expect("create run.log.zst");
1109        let mut encoder = zstd::stream::Encoder::new(log_file, 3).expect("create encoder");
1110        encoder.write_all(b"test").expect("write");
1111        encoder.finish().expect("finish");
1112
1113        let run_info = create_test_run_info(run_id);
1114        let result = PortableRecordingWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
1115
1116        assert!(
1117            matches!(
1118                &result,
1119                Err(PortableRecordingError::RequiredFileMissing { file_name, .. })
1120                if *file_name == STORE_ZIP_FILE_NAME
1121            ),
1122            "expected RequiredFileMissing for store.zip, got {result:?}"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_missing_run_log() {
1128        let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
1129        let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
1130        let runs_dir = temp_dir.path().to_owned();
1131        let run_dir = runs_dir.join(run_id.to_string());
1132        std::fs::create_dir_all(&run_dir).expect("create run dir");
1133
1134        let store_path = run_dir.join(STORE_ZIP_FILE_NAME);
1135        let store_file = File::create(&store_path).expect("create store.zip");
1136        let mut zip_writer = ArchiveWriter::new(store_file);
1137        let options = FileOptions::default();
1138        zip_writer
1139            .add_file("test.txt", &b"test"[..], &options)
1140            .expect("add file");
1141        zip_writer.finish().expect("finish");
1142
1143        let run_info = create_test_run_info(run_id);
1144        let result = PortableRecordingWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
1145
1146        assert!(
1147            matches!(
1148                &result,
1149                Err(PortableRecordingError::RequiredFileMissing { file_name, .. })
1150                if *file_name == RUN_LOG_FILE_NAME
1151            ),
1152            "expected RequiredFileMissing for run.log.zst, got {result:?}"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_ensure_seekable_regular_file() {
1158        // A regular file is already seekable and should be returned as-is.
1159        let temp = NamedUtf8TempFile::new().expect("created temp file");
1160        let path = temp.path().to_owned();
1161
1162        std::fs::write(&path, b"hello world").expect("wrote to temp file");
1163        let file = File::open(&path).expect("opened temp file");
1164
1165        // Get the file's OS-level fd/handle for identity comparison.
1166        #[cfg(unix)]
1167        let original_fd = {
1168            use std::os::unix::io::AsRawFd;
1169            file.as_raw_fd()
1170        };
1171
1172        let result = ensure_seekable(file, &path).expect("ensure_seekable succeeded");
1173
1174        // The returned file should be the same fd (no spooling occurred).
1175        #[cfg(unix)]
1176        {
1177            use std::os::unix::io::AsRawFd;
1178            assert_eq!(
1179                result.as_raw_fd(),
1180                original_fd,
1181                "seekable file should be returned as-is"
1182            );
1183        }
1184
1185        // Verify the content is still readable.
1186        let mut contents = String::new();
1187        let mut reader = io::BufReader::new(result);
1188        reader
1189            .read_to_string(&mut contents)
1190            .expect("read file contents");
1191        assert_eq!(contents, "hello world");
1192    }
1193
1194    /// Converts a `PipeReader` into a `File` using platform-specific owned
1195    /// I/O types.
1196    #[cfg(unix)]
1197    fn pipe_reader_to_file(reader: std::io::PipeReader) -> File {
1198        use std::os::fd::OwnedFd;
1199        File::from(OwnedFd::from(reader))
1200    }
1201
1202    /// Converts a `PipeReader` into a `File` using platform-specific owned
1203    /// I/O types.
1204    #[cfg(windows)]
1205    fn pipe_reader_to_file(reader: std::io::PipeReader) -> File {
1206        use std::os::windows::io::OwnedHandle;
1207        File::from(OwnedHandle::from(reader))
1208    }
1209
1210    /// Tests that non-seekable inputs (pipes) are spooled to a temp file.
1211    ///
1212    /// This test uses `std::io::pipe()` to create a real pipe, which is the
1213    /// same mechanism the OS uses for process substitution (`<(command)`).
1214    #[test]
1215    fn test_ensure_seekable_pipe() {
1216        let (pipe_reader, mut pipe_writer) = std::io::pipe().expect("created pipe");
1217        let test_data = b"zip-like test content for pipe spooling";
1218
1219        // Write data and close the write end so the read end reaches EOF.
1220        pipe_writer.write_all(test_data).expect("wrote to pipe");
1221        drop(pipe_writer);
1222
1223        let pipe_file = pipe_reader_to_file(pipe_reader);
1224
1225        let path = Utf8Path::new("/dev/fd/99");
1226        let result = ensure_seekable(pipe_file, path).expect("ensure_seekable succeeded");
1227
1228        // The result should be a seekable temp file containing the pipe data.
1229        let mut contents = Vec::new();
1230        let mut reader = io::BufReader::new(result);
1231        reader
1232            .read_to_end(&mut contents)
1233            .expect("read spooled contents");
1234        assert_eq!(contents, test_data);
1235    }
1236
1237    /// Tests that an empty pipe (zero bytes) is handled correctly.
1238    ///
1239    /// This simulates a download failure where the source produces no data.
1240    /// `ensure_seekable` should succeed (the temp file is created and rewound),
1241    /// and the downstream ZIP reader will report a proper error.
1242    #[test]
1243    fn test_ensure_seekable_empty_pipe() {
1244        let (pipe_reader, pipe_writer) = std::io::pipe().expect("created pipe");
1245        // Close writer immediately to produce an empty pipe.
1246        drop(pipe_writer);
1247
1248        let pipe_file = pipe_reader_to_file(pipe_reader);
1249        let path = Utf8Path::new("/dev/fd/42");
1250        let mut result = ensure_seekable(pipe_file, path).expect("empty pipe should succeed");
1251
1252        let mut contents = Vec::new();
1253        result.read_to_end(&mut contents).expect("read contents");
1254        assert!(contents.is_empty());
1255    }
1256
1257    /// Tests that the spool size limit is enforced for pipes.
1258    ///
1259    /// Uses `ensure_seekable_impl` with a small limit so we can trigger the
1260    /// `SpoolTooLarge` error without writing gigabytes.
1261    #[test]
1262    fn test_ensure_seekable_spool_too_large() {
1263        let (pipe_reader, mut pipe_writer) = std::io::pipe().expect("created pipe");
1264
1265        // Write 20 bytes, then set a limit of 10.
1266        pipe_writer
1267            .write_all(b"01234567890123456789")
1268            .expect("wrote to pipe");
1269        drop(pipe_writer);
1270
1271        let pipe_file = pipe_reader_to_file(pipe_reader);
1272
1273        let path = Utf8Path::new("/dev/fd/42");
1274        let result = ensure_seekable_impl(pipe_file, path, ByteSize(10));
1275        assert!(
1276            matches!(
1277                &result,
1278                Err(PortableRecordingReadError::SpoolTooLarge {
1279                    limit: ByteSize(10),
1280                    ..
1281                })
1282            ),
1283            "expected SpoolTooLarge, got {result:?}"
1284        );
1285    }
1286
1287    /// Tests that data exactly one byte over the spool limit fails.
1288    ///
1289    /// This is the precise boundary: `take(limit + 1)` reads exactly
1290    /// `limit + 1` bytes, and `bytes_copied > limit` triggers the error.
1291    #[test]
1292    fn test_ensure_seekable_spool_one_over_limit() {
1293        let (pipe_reader, mut pipe_writer) = std::io::pipe().expect("created pipe");
1294
1295        // Write exactly limit + 1 = 11 bytes with a limit of 10.
1296        pipe_writer
1297            .write_all(b"01234567890")
1298            .expect("wrote to pipe");
1299        drop(pipe_writer);
1300
1301        let pipe_file = pipe_reader_to_file(pipe_reader);
1302
1303        let path = Utf8Path::new("/dev/fd/42");
1304        let result = ensure_seekable_impl(pipe_file, path, ByteSize(10));
1305        assert!(
1306            matches!(
1307                &result,
1308                Err(PortableRecordingReadError::SpoolTooLarge {
1309                    limit: ByteSize(10),
1310                    ..
1311                })
1312            ),
1313            "expected SpoolTooLarge at limit+1 bytes, got {result:?}"
1314        );
1315    }
1316
1317    /// Tests that data exactly at the spool limit succeeds.
1318    #[test]
1319    fn test_ensure_seekable_spool_exact_limit() {
1320        let (pipe_reader, mut pipe_writer) = std::io::pipe().expect("created pipe");
1321
1322        // Write exactly 10 bytes with a limit of 10.
1323        pipe_writer.write_all(b"0123456789").expect("wrote to pipe");
1324        drop(pipe_writer);
1325
1326        let pipe_file = pipe_reader_to_file(pipe_reader);
1327
1328        let path = Utf8Path::new("/dev/fd/42");
1329        let mut result = ensure_seekable_impl(pipe_file, path, ByteSize(10))
1330            .expect("exact limit should succeed");
1331
1332        // Verify the spooled content is correct.
1333        let mut contents = Vec::new();
1334        result.read_to_end(&mut contents).expect("read contents");
1335        assert_eq!(contents, b"0123456789");
1336    }
1337}