1use 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#[derive(Debug)]
52pub struct PortableRecordingResult {
53 pub path: Utf8PathBuf,
55 pub size: u64,
57}
58
59#[derive(Debug)]
61pub struct ExtractOuterFileResult {
62 pub bytes_written: u64,
64 pub exceeded_limit: Option<u64>,
69}
70
71#[derive(Debug)]
73pub struct PortableRecordingWriter<'a> {
74 run_info: &'a RecordedRunInfo,
75 run_dir: Utf8PathBuf,
76}
77
78impl<'a> PortableRecordingWriter<'a> {
79 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 pub fn default_filename(&self) -> String {
115 format!("nextest-run-{}.zip", self.run_info.run_id)
116 }
117
118 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 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 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 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 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
227const SPOOL_SIZE_LIMIT: ByteSize = ByteSize(4 * 1024 * 1024 * 1024);
237
238#[cfg(windows)]
245enum WindowsFileKind {
246 Disk,
248 Pipe,
250 Other(u32),
253}
254
255#[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 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#[cfg(unix)]
273fn is_not_seekable_error(e: &io::Error) -> bool {
274 e.raw_os_error() == Some(libc::ESPIPE)
276}
277
278fn ensure_seekable(file: File, path: &Utf8Path) -> Result<File, PortableRecordingReadError> {
287 ensure_seekable_impl(file, path, SPOOL_SIZE_LIMIT)
288}
289
290fn ensure_seekable_impl(
294 file: File,
295 path: &Utf8Path,
296 spool_limit: ByteSize,
297) -> Result<File, PortableRecordingReadError> {
298 #[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 Err(PortableRecordingReadError::SeekProbe {
310 path: path.to_owned(),
311 error: e,
312 })
313 }
314 }
315 }
316
317 #[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
333fn 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 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 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
376type ArchiveReadStorage = Either<File, Cursor<Vec<u8>>>;
381
382pub 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 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 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 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 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 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 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 fn open_validated(
490 path: &Utf8Path,
491 mut outer_archive: Archive<BufReader<ArchiveReadStorage>>,
492 ) -> Result<Self, PortableRecordingReadError> {
493 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 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 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 pub fn archive_path(&self) -> &Utf8Path {
537 &self.archive_path
538 }
539
540 pub fn run_info(&self) -> RecordedRunInfo {
542 self.manifest.run_info()
543 }
544
545 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 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 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 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
631fn 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
669fn 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#[derive(Debug)]
728pub struct PortableRecordingRunLog {
729 archive_path: Utf8PathBuf,
730 run_log_bytes: Vec<u8>,
731}
732
733impl PortableRecordingRunLog {
734 pub fn events(
736 &self,
737 ) -> Result<RunLogEventIter<BufReader<zstd::stream::Decoder<'static, &[u8]>>>, RecordReadError>
738 {
739 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 Ok(RunLogEventIter::new(BufReader::new(decoder)))
750 }
751}
752
753pub struct PortableStoreReader<'a> {
758 archive_path: &'a Utf8Path,
759 store_archive: Archive<io::Take<&'a mut BufReader<ArchiveReadStorage>>>,
760 stdout_dict: Option<Vec<u8>>,
762 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 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 #[test]
1243 fn test_ensure_seekable_empty_pipe() {
1244 let (pipe_reader, pipe_writer) = std::io::pipe().expect("created pipe");
1245 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 #[test]
1262 fn test_ensure_seekable_spool_too_large() {
1263 let (pipe_reader, mut pipe_writer) = std::io::pipe().expect("created pipe");
1264
1265 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 #[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 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 #[test]
1319 fn test_ensure_seekable_spool_exact_limit() {
1320 let (pipe_reader, mut pipe_writer) = std::io::pipe().expect("created pipe");
1321
1322 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 let mut contents = Vec::new();
1334 result.read_to_end(&mut contents).expect("read contents");
1335 assert_eq!(contents, b"0123456789");
1336 }
1337}