Skip to main content

tzap_core/
streaming_writer.rs

1use std::io::{self, ErrorKind, Read};
2
3use crate::crypto::{KdfParams, MasterKey};
4use crate::format::{ArchiveWriteError, FormatError};
5use crate::metadata::{
6    normalize_lookup_file_path, validate_directory_path_bytes, validate_file_path_bytes,
7};
8use crate::writer::{
9    write_ordered_parallel_stream_archive_to_sink, ArchiveWriteSink, MemoryArchiveSink,
10    RootAuthAuthenticator, RootAuthWriterConfig, StreamingRegularMember, WriterOptions,
11    WrittenArchive, WrittenArchiveSummary,
12};
13
14const TAR_BLOCK_LEN: usize = 512;
15const MAX_TAR_STDIN_METADATA_PAYLOAD_BYTES: usize = 1024 * 1024;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct StreamingTarWriterSummary {
19    pub archive: WrittenArchiveSummary,
20    pub input_member_count: u64,
21    pub input_tar_bytes: u64,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct StreamingRawWriterSummary {
26    pub archive: WrittenArchiveSummary,
27    pub input_bytes: u64,
28}
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31struct TarStdinInputSummary {
32    regular_file_count: u64,
33    skipped_directory_count: u64,
34    input_tar_bytes: u64,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct TarStdinRegularMember {
39    path: Vec<u8>,
40    mode: u32,
41    mtime: u64,
42    logical_size: u64,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46struct LocalTarMetadata {
47    pending_header: bool,
48    pax_path: Option<Vec<u8>>,
49    pax_size: Option<u64>,
50    pax_mode: Option<u32>,
51    pax_mtime: Option<u64>,
52    gnu_long_name: Option<Vec<u8>>,
53}
54
55impl LocalTarMetadata {
56    fn has_pending(&self) -> bool {
57        self.pending_header
58            || self.pax_path.is_some()
59            || self.pax_size.is_some()
60            || self.pax_mode.is_some()
61            || self.pax_mtime.is_some()
62            || self.gnu_long_name.is_some()
63    }
64}
65
66pub fn write_tar_stream_archive<R: Read>(
67    reader: R,
68    master_key: &MasterKey,
69    options: WriterOptions,
70) -> Result<WrittenArchive, FormatError> {
71    let mut sink = MemoryArchiveSink::default();
72    let summary = write_tar_stream_archive_to_sink(reader, master_key, options, &mut sink)
73        .map_err(format_error_from_archive_write_error)?;
74    Ok(WrittenArchive {
75        bytes: sink
76            .volumes
77            .first()
78            .cloned()
79            .ok_or(FormatError::WriterInvariant("no volumes emitted"))?,
80        volumes: sink.volumes,
81        bootstrap_sidecar: sink.bootstrap_sidecar,
82        archive_uuid: summary.archive.archive_uuid,
83        session_id: summary.archive.session_id,
84        timings: summary.archive.timings,
85    })
86}
87
88pub fn write_tar_stream_archive_to_sink<R, O>(
89    reader: R,
90    master_key: &MasterKey,
91    options: WriterOptions,
92    sink: &mut O,
93) -> Result<StreamingTarWriterSummary, ArchiveWriteError>
94where
95    R: Read,
96    O: ArchiveWriteSink,
97{
98    write_tar_stream_archive_to_sink_with_kdf_and_root_auth(
99        reader,
100        master_key,
101        options,
102        &KdfParams::Raw,
103        None,
104        None,
105        sink,
106    )
107}
108
109#[allow(clippy::too_many_arguments)]
110pub fn write_sized_raw_member_archive_to_sink_with_kdf_and_root_auth<R, O>(
111    mut reader: R,
112    archive_path: &str,
113    input_size: u64,
114    master_key: &MasterKey,
115    options: WriterOptions,
116    kdf_params: &KdfParams,
117    root_auth: Option<RootAuthWriterConfig<'_>>,
118    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
119    sink: &mut O,
120) -> Result<StreamingRawWriterSummary, ArchiveWriteError>
121where
122    R: Read,
123    O: ArchiveWriteSink,
124{
125    validate_streaming_create_writer_options(options)?;
126    let archive_path = normalize_lookup_file_path(archive_path, options.max_path_length)?;
127    let archive = write_ordered_parallel_stream_archive_to_sink(
128        master_key,
129        options,
130        kdf_params,
131        root_auth,
132        authenticator,
133        sink,
134        |writer| {
135            let mut payload = SizedRawPayloadReader {
136                reader: &mut reader,
137                remaining: input_size,
138            };
139            writer.write_regular_member_from_reader(
140                StreamingRegularMember {
141                    archive_path,
142                    file_data_size: input_size,
143                    mode: 0o644,
144                    mtime: 0,
145                },
146                &mut payload,
147            )?;
148            if payload.remaining != 0 {
149                return Err(FormatError::WriterInvariant(
150                    "raw stdin payload was not fully consumed",
151                )
152                .into());
153            }
154            reject_trailing_raw_stdin_bytes(&mut reader)?;
155            Ok(())
156        },
157    )?;
158    Ok(StreamingRawWriterSummary {
159        archive,
160        input_bytes: input_size,
161    })
162}
163
164#[allow(clippy::too_many_arguments)]
165pub fn write_tar_stream_archive_to_sink_with_kdf_and_root_auth<R, O>(
166    mut reader: R,
167    master_key: &MasterKey,
168    options: WriterOptions,
169    kdf_params: &KdfParams,
170    root_auth: Option<RootAuthWriterConfig<'_>>,
171    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
172    sink: &mut O,
173) -> Result<StreamingTarWriterSummary, ArchiveWriteError>
174where
175    R: Read,
176    O: ArchiveWriteSink,
177{
178    validate_streaming_create_writer_options(options)?;
179    let mut input_summary = None;
180    let archive = write_ordered_parallel_stream_archive_to_sink(
181        master_key,
182        options,
183        kdf_params,
184        root_auth,
185        authenticator,
186        sink,
187        |writer| {
188            let summary = stream_tar_stdin_regulars(
189                &mut reader,
190                options.max_path_length,
191                |member, payload| {
192                    writer.write_regular_member_from_reader(
193                        StreamingRegularMember {
194                            archive_path: member.path,
195                            file_data_size: member.logical_size,
196                            mode: member.mode,
197                            mtime: member.mtime,
198                        },
199                        payload,
200                    )
201                },
202            )?;
203            input_summary = Some(summary);
204            Ok(())
205        },
206    )?;
207    let input_summary = input_summary.ok_or(FormatError::WriterInvariant(
208        "streaming tar parser did not return a summary",
209    ))?;
210    Ok(StreamingTarWriterSummary {
211        archive,
212        input_member_count: input_summary.regular_file_count,
213        input_tar_bytes: input_summary.input_tar_bytes,
214    })
215}
216
217fn format_error_from_archive_write_error(error: ArchiveWriteError) -> FormatError {
218    match error {
219        ArchiveWriteError::Format(error) => error,
220        ArchiveWriteError::Io(_) => FormatError::WriterInvariant("in-memory archive writer failed"),
221    }
222}
223
224fn validate_streaming_create_writer_options(options: WriterOptions) -> Result<(), FormatError> {
225    if options.volume_loss_tolerance != 0 {
226        return Err(FormatError::WriterUnsupported(
227            "streaming create cannot tolerate volume loss",
228        ));
229    }
230    if options.target_volume_size.is_some() {
231        return Err(FormatError::WriterUnsupported(
232            "streaming create does not support target volume sizing",
233        ));
234    }
235    Ok(())
236}
237
238struct SizedRawPayloadReader<'a, R: Read> {
239    reader: &'a mut R,
240    remaining: u64,
241}
242
243impl<R: Read> Read for SizedRawPayloadReader<'_, R> {
244    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
245        if out.is_empty() || self.remaining == 0 {
246            return Ok(0);
247        }
248        let max_read = out
249            .len()
250            .min(usize::try_from(self.remaining).unwrap_or(usize::MAX));
251        match self.reader.read(&mut out[..max_read]) {
252            Ok(read) => {
253                self.remaining -= read as u64;
254                Ok(read)
255            }
256            Err(error) if error.kind() == ErrorKind::Interrupted => self.read(out),
257            Err(error) => Err(error),
258        }
259    }
260}
261
262fn reject_trailing_raw_stdin_bytes<R: Read>(reader: &mut R) -> Result<(), ArchiveWriteError> {
263    let mut extra = [0u8; 1];
264    loop {
265        match reader.read(&mut extra) {
266            Ok(0) => return Ok(()),
267            Ok(_) => {
268                return Err(
269                    FormatError::InvalidArchive("raw stdin exceeds declared --stdin-size").into(),
270                )
271            }
272            Err(error) if error.kind() == ErrorKind::Interrupted => {}
273            Err(error) => return Err(ArchiveWriteError::Io(error)),
274        }
275    }
276}
277
278fn stream_tar_stdin_regulars<R, F>(
279    reader: &mut R,
280    max_path_length: u32,
281    mut on_regular: F,
282) -> Result<TarStdinInputSummary, ArchiveWriteError>
283where
284    R: Read,
285    F: for<'a> FnMut(
286        TarStdinRegularMember,
287        &mut LimitedTarPayloadReader<'a, R>,
288    ) -> Result<(), ArchiveWriteError>,
289{
290    let mut summary = TarStdinInputSummary::default();
291    let mut metadata = LocalTarMetadata::default();
292
293    loop {
294        let Some(header) = read_tar_block(reader, &mut summary.input_tar_bytes)? else {
295            if metadata.has_pending() {
296                return Err(
297                    FormatError::InvalidArchive("tar stream ended after metadata header").into(),
298                );
299            }
300            return Ok(summary);
301        };
302        if header.iter().all(|byte| *byte == 0) {
303            if metadata.has_pending() {
304                return Err(
305                    FormatError::InvalidArchive("tar stream ended after metadata header").into(),
306                );
307            }
308            let second = read_tar_block(reader, &mut summary.input_tar_bytes)?.ok_or(
309                FormatError::InvalidArchive("tar stream ended inside end-of-archive marker"),
310            )?;
311            if second.iter().any(|byte| *byte != 0) {
312                return Err(FormatError::InvalidArchive(
313                    "tar stream has non-zero bytes after end-of-archive marker",
314                )
315                .into());
316            }
317            drain_zero_blocks_to_eof(reader, &mut summary.input_tar_bytes)?;
318            return Ok(summary);
319        }
320
321        verify_tar_checksum(&header)?;
322        let typeflag = header[156];
323        let header_size = parse_tar_number(&header[124..136])?;
324        match typeflag {
325            b'x' => {
326                let payload =
327                    read_metadata_payload(reader, header_size, &mut summary.input_tar_bytes)?;
328                metadata.pending_header = true;
329                parse_pax_records(&payload, &mut metadata)?;
330            }
331            b'L' => {
332                let payload =
333                    read_metadata_payload(reader, header_size, &mut summary.input_tar_bytes)?;
334                metadata.pending_header = true;
335                metadata.gnu_long_name = Some(trimmed_metadata_payload(&payload));
336            }
337            b'K' => {
338                let _payload =
339                    read_metadata_payload(reader, header_size, &mut summary.input_tar_bytes)?;
340                metadata.pending_header = true;
341            }
342            b'g' => {
343                return Err(
344                    FormatError::InvalidArchive("global PAX headers are not allowed").into(),
345                )
346            }
347            b'V' | b'M' | b'N' => {
348                return Err(
349                    FormatError::InvalidArchive("global GNU headers are not allowed").into(),
350                )
351            }
352            b'S' => {
353                return Err(
354                    FormatError::ReaderUnsupported("unsupported GNU sparse tar entry").into(),
355                )
356            }
357            0 | b'0' | b'5' | b'1' | b'2' | b'3' | b'4' | b'6' => {
358                let effective_size = metadata.pax_size.unwrap_or(header_size);
359                let mode = metadata
360                    .pax_mode
361                    .unwrap_or(parse_tar_number(&header[100..108])? as u32);
362                let mtime = metadata
363                    .pax_mtime
364                    .unwrap_or(parse_tar_number(&header[136..148])?);
365                let path = canonical_main_path(&header, typeflag, &metadata, max_path_length)?;
366                metadata = LocalTarMetadata::default();
367
368                match typeflag {
369                    0 | b'0' => {
370                        let member = TarStdinRegularMember {
371                            path,
372                            mode,
373                            mtime,
374                            logical_size: effective_size,
375                        };
376                        {
377                            let mut payload = LimitedTarPayloadReader {
378                                reader,
379                                remaining: effective_size,
380                                input_tar_bytes: &mut summary.input_tar_bytes,
381                            };
382                            on_regular(member, &mut payload)?;
383                            if payload.remaining != 0 {
384                                return Err(FormatError::WriterInvariant(
385                                    "streaming tar payload was not fully consumed",
386                                )
387                                .into());
388                            }
389                        }
390                        drain_tar_padding(
391                            reader,
392                            padding_to_512_u64(effective_size),
393                            &mut summary.input_tar_bytes,
394                        )?;
395                        summary.regular_file_count = checked_input_add(
396                            summary.regular_file_count,
397                            1,
398                            "tar regular file count",
399                        )?;
400                    }
401                    b'5' => {
402                        if effective_size != 0 {
403                            return Err(FormatError::InvalidArchive(
404                                "non-regular tar entry has non-zero payload size",
405                            )
406                            .into());
407                        }
408                        summary.skipped_directory_count = checked_input_add(
409                            summary.skipped_directory_count,
410                            1,
411                            "tar directory count",
412                        )?;
413                    }
414                    _ => {
415                        return Err(FormatError::WriterUnsupported(
416                            "streaming tar stdin supports regular files and directory entries only",
417                        )
418                        .into())
419                    }
420                }
421            }
422            _ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type").into()),
423        }
424    }
425}
426
427struct LimitedTarPayloadReader<'a, R: Read> {
428    reader: &'a mut R,
429    remaining: u64,
430    input_tar_bytes: &'a mut u64,
431}
432
433impl<R: Read> Read for LimitedTarPayloadReader<'_, R> {
434    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
435        if out.is_empty() || self.remaining == 0 {
436            return Ok(0);
437        }
438        let max_read = out
439            .len()
440            .min(usize::try_from(self.remaining).unwrap_or(usize::MAX));
441        match self.reader.read(&mut out[..max_read]) {
442            Ok(0) => Err(io::Error::new(
443                ErrorKind::UnexpectedEof,
444                "tar stream ended inside member payload",
445            )),
446            Ok(read) => {
447                self.remaining -= read as u64;
448                *self.input_tar_bytes =
449                    checked_input_add(*self.input_tar_bytes, read as u64, "tar input size")
450                        .map_err(io_error_from_format)?;
451                Ok(read)
452            }
453            Err(error) if error.kind() == ErrorKind::Interrupted => self.read(out),
454            Err(error) => Err(error),
455        }
456    }
457}
458
459fn read_tar_block<R: Read>(
460    reader: &mut R,
461    input_tar_bytes: &mut u64,
462) -> Result<Option<[u8; TAR_BLOCK_LEN]>, ArchiveWriteError> {
463    let mut block = [0u8; TAR_BLOCK_LEN];
464    let mut filled = 0usize;
465    while filled < TAR_BLOCK_LEN {
466        match reader.read(&mut block[filled..]) {
467            Ok(0) if filled == 0 => return Ok(None),
468            Ok(0) => {
469                return Err(
470                    FormatError::InvalidArchive("tar stream ended inside member group").into(),
471                )
472            }
473            Ok(read) => {
474                filled += read;
475                *input_tar_bytes =
476                    checked_input_add(*input_tar_bytes, read as u64, "tar input size")?;
477            }
478            Err(error) if error.kind() == ErrorKind::Interrupted => {}
479            Err(error) => return Err(ArchiveWriteError::Io(error)),
480        }
481    }
482    Ok(Some(block))
483}
484
485fn read_metadata_payload<R: Read>(
486    reader: &mut R,
487    size: u64,
488    input_tar_bytes: &mut u64,
489) -> Result<Vec<u8>, ArchiveWriteError> {
490    let len = usize::try_from(size)
491        .map_err(|_| FormatError::ReaderUnsupported("tar metadata payload is too large"))?;
492    if len > MAX_TAR_STDIN_METADATA_PAYLOAD_BYTES {
493        return Err(
494            FormatError::ReaderUnsupported("tar metadata payload exceeds streaming cap").into(),
495        );
496    }
497    let mut payload = vec![0u8; len];
498    read_exact_counted(reader, &mut payload, input_tar_bytes)?;
499    drain_tar_padding(reader, padding_to_512_u64(size), input_tar_bytes)?;
500    Ok(payload)
501}
502
503fn read_exact_counted<R: Read>(
504    reader: &mut R,
505    mut out: &mut [u8],
506    input_tar_bytes: &mut u64,
507) -> Result<(), ArchiveWriteError> {
508    while !out.is_empty() {
509        match reader.read(out) {
510            Ok(0) => {
511                return Err(
512                    FormatError::InvalidArchive("tar stream ended inside member group").into(),
513                )
514            }
515            Ok(read) => {
516                *input_tar_bytes =
517                    checked_input_add(*input_tar_bytes, read as u64, "tar input size")?;
518                let remaining = out;
519                out = &mut remaining[read..];
520            }
521            Err(error) if error.kind() == ErrorKind::Interrupted => {}
522            Err(error) => return Err(ArchiveWriteError::Io(error)),
523        }
524    }
525    Ok(())
526}
527
528fn drain_tar_padding<R: Read>(
529    reader: &mut R,
530    padding: u64,
531    input_tar_bytes: &mut u64,
532) -> Result<(), ArchiveWriteError> {
533    let mut remaining = padding;
534    let mut buf = [0u8; TAR_BLOCK_LEN];
535    while remaining > 0 {
536        let take = buf
537            .len()
538            .min(usize::try_from(remaining).unwrap_or(usize::MAX));
539        read_exact_counted(reader, &mut buf[..take], input_tar_bytes)?;
540        if buf[..take].iter().any(|byte| *byte != 0) {
541            return Err(FormatError::InvalidArchive("tar member padding is non-zero").into());
542        }
543        remaining -= take as u64;
544    }
545    Ok(())
546}
547
548fn drain_zero_blocks_to_eof<R: Read>(
549    reader: &mut R,
550    input_tar_bytes: &mut u64,
551) -> Result<(), ArchiveWriteError> {
552    while let Some(block) = read_tar_block(reader, input_tar_bytes)? {
553        if block.iter().any(|byte| *byte != 0) {
554            return Err(FormatError::InvalidArchive(
555                "tar stream has non-zero bytes after end-of-archive marker",
556            )
557            .into());
558        }
559    }
560    Ok(())
561}
562
563fn canonical_main_path(
564    header: &[u8],
565    typeflag: u8,
566    metadata: &LocalTarMetadata,
567    max_path_length: u32,
568) -> Result<Vec<u8>, FormatError> {
569    let mut path = metadata
570        .pax_path
571        .clone()
572        .or_else(|| metadata.gnu_long_name.clone())
573        .unwrap_or_else(|| ustar_path(header));
574    while path.starts_with(b"./") {
575        path.drain(..2);
576    }
577    if typeflag == b'5' && path.ends_with(b"/") && !path.ends_with(b"//") {
578        path.pop();
579    }
580    if typeflag == b'5' {
581        validate_directory_path_bytes(&path, max_path_length)?;
582    } else {
583        validate_file_path_bytes(&path, max_path_length)?;
584    }
585    Ok(path)
586}
587
588fn ustar_path(header: &[u8]) -> Vec<u8> {
589    let name = nul_trimmed(&header[0..100]);
590    let prefix = nul_trimmed(&header[345..500]);
591    if prefix.is_empty() {
592        return name.to_vec();
593    }
594    let mut path = Vec::with_capacity(prefix.len() + 1 + name.len());
595    path.extend_from_slice(prefix);
596    path.push(b'/');
597    path.extend_from_slice(name);
598    path
599}
600
601fn parse_pax_records(payload: &[u8], metadata: &mut LocalTarMetadata) -> Result<(), FormatError> {
602    let mut cursor = 0usize;
603    while cursor < payload.len() {
604        let len_digits_start = cursor;
605        while cursor < payload.len() && payload[cursor].is_ascii_digit() {
606            cursor += 1;
607        }
608        if cursor == len_digits_start || cursor >= payload.len() || payload[cursor] != b' ' {
609            return Err(FormatError::InvalidArchive("malformed PAX record"));
610        }
611        let len = parse_decimal_usize(&payload[len_digits_start..cursor])?;
612        let record_start = len_digits_start;
613        let record_end = record_start
614            .checked_add(len)
615            .ok_or(FormatError::InvalidArchive("malformed PAX record"))?;
616        if record_end > payload.len() || len < 4 {
617            return Err(FormatError::InvalidArchive("malformed PAX record"));
618        }
619        let body_start = cursor + 1;
620        let record = &payload[body_start..record_end];
621        if record.last().copied() != Some(b'\n') {
622            return Err(FormatError::InvalidArchive("malformed PAX record"));
623        }
624        let body = &record[..record.len() - 1];
625        let eq = body
626            .iter()
627            .position(|byte| *byte == b'=')
628            .ok_or(FormatError::InvalidArchive("malformed PAX record"))?;
629        let key = std::str::from_utf8(&body[..eq])
630            .map_err(|_| FormatError::InvalidArchive("malformed PAX key"))?;
631        let value = &body[eq + 1..];
632        match key {
633            "path" => metadata.pax_path = Some(value.to_vec()),
634            "size" => metadata.pax_size = Some(parse_decimal_u64(value)?),
635            "mode" => {
636                metadata.pax_mode = Some(
637                    u32::try_from(parse_decimal_u64(value)?)
638                        .map_err(|_| FormatError::WriterUnsupported("tar mode overflow"))?,
639                )
640            }
641            "mtime" => metadata.pax_mtime = Some(parse_pax_mtime(value)?),
642            key if key.starts_with("GNU.sparse.") => {
643                return Err(FormatError::ReaderUnsupported(
644                    "unsupported GNU sparse tar entry",
645                ))
646            }
647            _ => {}
648        }
649        cursor = record_end;
650    }
651    Ok(())
652}
653
654fn verify_tar_checksum(header: &[u8]) -> Result<(), FormatError> {
655    let expected = parse_tar_number(&header[148..156])?;
656    let actual = header[..148]
657        .iter()
658        .chain(header[156..].iter())
659        .fold(8u64 * 32, |sum, byte| sum + u64::from(*byte));
660    if actual != expected {
661        return Err(FormatError::InvalidArchive("tar header checksum mismatch"));
662    }
663    Ok(())
664}
665
666fn parse_tar_number(field: &[u8]) -> Result<u64, FormatError> {
667    if field.first().is_some_and(|byte| byte & 0x80 != 0) {
668        return parse_tar_base256(field);
669    }
670    parse_tar_octal(field)
671}
672
673fn parse_tar_base256(field: &[u8]) -> Result<u64, FormatError> {
674    let Some(first) = field.first() else {
675        return Err(FormatError::InvalidArchive("empty tar numeric field"));
676    };
677    if first & 0x40 != 0 {
678        return Err(FormatError::ReaderUnsupported(
679            "negative tar base-256 numeric fields are not supported",
680        ));
681    }
682    let mut value = u128::from(first & 0x7f);
683    for byte in &field[1..] {
684        value = value
685            .checked_mul(256)
686            .and_then(|current| current.checked_add(u128::from(*byte)))
687            .ok_or(FormatError::InvalidArchive("tar base-256 field overflow"))?;
688    }
689    u64::try_from(value).map_err(|_| FormatError::ReaderUnsupported("tar numeric field too large"))
690}
691
692fn parse_tar_octal(field: &[u8]) -> Result<u64, FormatError> {
693    let field = field
694        .split(|byte| *byte == 0)
695        .next()
696        .unwrap_or(field)
697        .iter()
698        .copied()
699        .skip_while(|byte| *byte == b' ' || *byte == b'0')
700        .take_while(|byte| *byte != b' ')
701        .collect::<Vec<_>>();
702    if field.is_empty() {
703        return Ok(0);
704    }
705    let mut value = 0u64;
706    for byte in field {
707        if !(b'0'..=b'7').contains(&byte) {
708            return Err(FormatError::InvalidArchive("invalid tar octal field"));
709        }
710        value = value
711            .checked_mul(8)
712            .and_then(|current| current.checked_add(u64::from(byte - b'0')))
713            .ok_or(FormatError::InvalidArchive("tar octal field overflow"))?;
714    }
715    Ok(value)
716}
717
718fn parse_decimal_usize(bytes: &[u8]) -> Result<usize, FormatError> {
719    usize::try_from(parse_decimal_u64(bytes)?)
720        .map_err(|_| FormatError::InvalidArchive("decimal field overflow"))
721}
722
723fn parse_decimal_u64(bytes: &[u8]) -> Result<u64, FormatError> {
724    if bytes.is_empty() {
725        return Err(FormatError::InvalidArchive("malformed decimal field"));
726    }
727    let mut value = 0u64;
728    for byte in bytes {
729        if !byte.is_ascii_digit() {
730            return Err(FormatError::InvalidArchive("malformed decimal field"));
731        }
732        value = value
733            .checked_mul(10)
734            .and_then(|current| current.checked_add(u64::from(byte - b'0')))
735            .ok_or(FormatError::InvalidArchive("decimal field overflow"))?;
736    }
737    Ok(value)
738}
739
740fn parse_pax_mtime(bytes: &[u8]) -> Result<u64, FormatError> {
741    let whole = bytes.split(|byte| *byte == b'.').next().unwrap_or(bytes);
742    parse_decimal_u64(whole)
743}
744
745fn trimmed_metadata_payload(payload: &[u8]) -> Vec<u8> {
746    let mut end = payload.len();
747    while end > 0 && payload[end - 1] == 0 {
748        end -= 1;
749    }
750    payload[..end].to_vec()
751}
752
753fn nul_trimmed(bytes: &[u8]) -> &[u8] {
754    let end = bytes
755        .iter()
756        .position(|byte| *byte == 0)
757        .unwrap_or(bytes.len());
758    &bytes[..end]
759}
760
761fn padding_to_512_u64(len: u64) -> u64 {
762    let remainder = len % TAR_BLOCK_LEN as u64;
763    if remainder == 0 {
764        0
765    } else {
766        TAR_BLOCK_LEN as u64 - remainder
767    }
768}
769
770fn checked_input_add(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
771    lhs.checked_add(rhs)
772        .ok_or(FormatError::WriterUnsupported(field))
773}
774
775fn io_error_from_format(error: FormatError) -> io::Error {
776    io::Error::new(ErrorKind::InvalidData, error.to_string())
777}
778
779#[cfg(test)]
780mod tests {
781    use std::io;
782
783    use crate::crypto::MasterKey;
784    use crate::format::{FormatError, BLOCK_RECORD_FRAMING_LEN, MASTER_KEY_LEN, VOLUME_HEADER_LEN};
785    use crate::reader::{open_archive, open_archive_volumes};
786    use crate::root_auth::data_block_merkle_leaf_hash;
787    use crate::wire::{BlockRecord, CryptoHeader, VolumeHeader};
788    use crate::writer::{write_archive, RegularFile};
789
790    use super::*;
791
792    fn master_key() -> MasterKey {
793        MasterKey::from_raw_key(&[0x31; MASTER_KEY_LEN]).unwrap()
794    }
795
796    fn options() -> WriterOptions {
797        WriterOptions {
798            archive_uuid: Some([0x41; 16]),
799            session_id: Some([0x42; 16]),
800            closed_at_ns: 987_654_321,
801            stripe_width: 1,
802            volume_loss_tolerance: 0,
803            bit_rot_buffer_pct: 0,
804            index_root_fec_parity_shards: 0,
805            ..WriterOptions::default()
806        }
807    }
808
809    fn single_pass_equivalence_options() -> WriterOptions {
810        WriterOptions {
811            // The single-pass writer must commit the CryptoHeader before stdin is
812            // consumed, so it predeclares the largest supported IndexRoot class.
813            // Compare byte identity against the legacy writer with that same
814            // declared class, not against the legacy default that can be raised
815            // after payload planning.
816            index_root_fec_data_shards: u16::MAX,
817            ..options()
818        }
819    }
820
821    fn multi_volume_options(stripe_width: u32) -> WriterOptions {
822        WriterOptions {
823            stripe_width,
824            volume_loss_tolerance: 0,
825            ..options()
826        }
827    }
828
829    fn tar_stream(entries: &[(&str, &[u8])]) -> Vec<u8> {
830        let mut out = Vec::new();
831        for (path, data) in entries {
832            out.extend_from_slice(&tar_header(path.as_bytes(), b'0', data.len() as u64));
833            out.extend_from_slice(data);
834            out.resize(out.len() + padding_to_512(data.len()), 0);
835        }
836        out.extend_from_slice(&[0u8; TAR_BLOCK_LEN * 2]);
837        out
838    }
839
840    fn tar_header(path: &[u8], kind: u8, size: u64) -> [u8; TAR_BLOCK_LEN] {
841        let mut header = [0u8; TAR_BLOCK_LEN];
842        header[..path.len()].copy_from_slice(path);
843        write_octal(&mut header[100..108], 0o644);
844        write_octal(&mut header[108..116], 0);
845        write_octal(&mut header[116..124], 0);
846        write_octal(&mut header[124..136], size);
847        write_octal(&mut header[136..148], 0);
848        header[148..156].fill(b' ');
849        header[156] = kind;
850        header[257..263].copy_from_slice(b"ustar\0");
851        header[263..265].copy_from_slice(b"00");
852        let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
853        write_checksum(&mut header[148..156], checksum);
854        header
855    }
856
857    fn write_octal(field: &mut [u8], value: u64) {
858        let digits = format!("{value:o}");
859        field.fill(0);
860        let start = field.len() - 1 - digits.len();
861        field[..start].fill(b'0');
862        field[start..start + digits.len()].copy_from_slice(digits.as_bytes());
863    }
864
865    fn write_checksum(field: &mut [u8], value: u64) {
866        let digits = format!("{value:06o}");
867        field[0..6].copy_from_slice(digits.as_bytes());
868        field[6] = 0;
869        field[7] = b' ';
870    }
871
872    fn write_base256(field: &mut [u8], value: u64) {
873        field.fill(0);
874        let mut value = value;
875        for byte in field.iter_mut().rev() {
876            *byte = (value & 0xff) as u8;
877            value >>= 8;
878        }
879        field[0] |= 0x80;
880    }
881
882    fn padding_to_512(len: usize) -> usize {
883        let remainder = len % TAR_BLOCK_LEN;
884        if remainder == 0 {
885            0
886        } else {
887            TAR_BLOCK_LEN - remainder
888        }
889    }
890
891    fn data_leaf_hash_sequence(bytes: &[u8]) -> Vec<(u64, [u8; 32])> {
892        let volume_header = VolumeHeader::parse(&bytes[..VOLUME_HEADER_LEN]).unwrap();
893        let crypto_start = volume_header.crypto_header_offset as usize;
894        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
895        let crypto_header = CryptoHeader::parse(
896            &bytes[crypto_start..crypto_end],
897            volume_header.crypto_header_length,
898        )
899        .unwrap();
900        let block_size = crypto_header.fixed.block_size as usize;
901        let record_len = block_size + BLOCK_RECORD_FRAMING_LEN;
902        let mut offset = crypto_end;
903        let mut hashes = Vec::new();
904        while bytes.get(offset..offset + 4) == Some(b"TZBK") {
905            let record =
906                BlockRecord::parse(&bytes[offset..offset + record_len], block_size).unwrap();
907            if record.kind.is_data() {
908                hashes.push((
909                    record.block_index,
910                    data_block_merkle_leaf_hash(
911                        record.block_index,
912                        record.kind,
913                        record.flags,
914                        &record.payload,
915                    ),
916                ));
917            }
918            offset += record_len;
919        }
920        hashes
921    }
922
923    struct TinyReadCursor {
924        data: Vec<u8>,
925        cursor: usize,
926        max_chunk: usize,
927        reads: usize,
928    }
929
930    impl TinyReadCursor {
931        fn new(data: Vec<u8>, max_chunk: usize) -> Self {
932            Self {
933                data,
934                cursor: 0,
935                max_chunk,
936                reads: 0,
937            }
938        }
939    }
940
941    impl Read for TinyReadCursor {
942        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
943            self.reads += 1;
944            if self.cursor >= self.data.len() {
945                return Ok(0);
946            }
947            let len = self
948                .max_chunk
949                .min(buf.len())
950                .min(self.data.len() - self.cursor);
951            buf[..len].copy_from_slice(&self.data[self.cursor..self.cursor + len]);
952            self.cursor += len;
953            Ok(len)
954        }
955    }
956
957    #[test]
958    fn tar_stdin_single_volume_round_trips_list_verify_and_extract() {
959        let input = tar_stream(&[
960            ("alpha.txt", b"alpha payload".as_slice()),
961            ("dir/beta.txt", b"beta payload".as_slice()),
962        ]);
963        let mut reader = TinyReadCursor::new(input, 17);
964
965        let archive = write_tar_stream_archive(&mut reader, &master_key(), options()).unwrap();
966
967        assert!(reader.reads > 10);
968        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
969        opened.verify().unwrap();
970        let listed = opened
971            .list_files()
972            .unwrap()
973            .into_iter()
974            .map(|entry| entry.path)
975            .collect::<Vec<_>>();
976        assert_eq!(listed, vec!["alpha.txt", "dir/beta.txt"]);
977        assert_eq!(
978            opened.extract_file("dir/beta.txt").unwrap(),
979            Some(b"beta payload".to_vec())
980        );
981    }
982
983    #[test]
984    fn sized_raw_stdin_round_trips_as_regular_tar_member_archive() {
985        let input = b"raw bytes from stdin";
986        let mut sink = MemoryArchiveSink::default();
987
988        let summary = write_sized_raw_member_archive_to_sink_with_kdf_and_root_auth(
989            input.as_slice(),
990            "raw/data.bin",
991            input.len() as u64,
992            &master_key(),
993            options(),
994            &KdfParams::Raw,
995            None,
996            None,
997            &mut sink,
998        )
999        .unwrap();
1000
1001        assert_eq!(summary.input_bytes, input.len() as u64);
1002        let opened = open_archive(&sink.volumes[0], &master_key()).unwrap();
1003        opened.verify().unwrap();
1004        assert_eq!(
1005            opened.extract_file("raw/data.bin").unwrap(),
1006            Some(input.to_vec())
1007        );
1008    }
1009
1010    #[test]
1011    fn sized_raw_stdin_known_size_multi_volume_round_trips() {
1012        let input = (0..150_000)
1013            .map(|index| (index % 251) as u8)
1014            .collect::<Vec<_>>();
1015        let mut sink = MemoryArchiveSink::default();
1016
1017        let summary = write_sized_raw_member_archive_to_sink_with_kdf_and_root_auth(
1018            input.as_slice(),
1019            "raw/data.bin",
1020            input.len() as u64,
1021            &master_key(),
1022            multi_volume_options(3),
1023            &KdfParams::Raw,
1024            None,
1025            None,
1026            &mut sink,
1027        )
1028        .unwrap();
1029
1030        assert_eq!(summary.archive.volume_count, 3);
1031        assert_eq!(sink.volumes.len(), 3);
1032        let refs = sink.volumes.iter().map(Vec::as_slice).collect::<Vec<_>>();
1033        let opened = open_archive_volumes(&refs, &master_key()).unwrap();
1034        opened.verify().unwrap();
1035        assert_eq!(
1036            opened.extract_file("raw/data.bin").unwrap(),
1037            Some(input.to_vec())
1038        );
1039    }
1040
1041    #[test]
1042    fn sized_raw_stdin_rejects_short_input() {
1043        let mut sink = MemoryArchiveSink::default();
1044
1045        let error = write_sized_raw_member_archive_to_sink_with_kdf_and_root_auth(
1046            b"short".as_slice(),
1047            "raw/data.bin",
1048            6,
1049            &master_key(),
1050            options(),
1051            &KdfParams::Raw,
1052            None,
1053            None,
1054            &mut sink,
1055        )
1056        .unwrap_err();
1057
1058        assert!(matches!(error, ArchiveWriteError::Io(_)));
1059    }
1060
1061    #[test]
1062    fn sized_raw_stdin_rejects_trailing_input() {
1063        let mut sink = MemoryArchiveSink::default();
1064
1065        let error = write_sized_raw_member_archive_to_sink_with_kdf_and_root_auth(
1066            b"toolong".as_slice(),
1067            "raw/data.bin",
1068            3,
1069            &master_key(),
1070            options(),
1071            &KdfParams::Raw,
1072            None,
1073            None,
1074            &mut sink,
1075        )
1076        .unwrap_err();
1077
1078        assert!(matches!(
1079            error,
1080            ArchiveWriteError::Format(FormatError::InvalidArchive(
1081                "raw stdin exceeds declared --stdin-size"
1082            ))
1083        ));
1084    }
1085
1086    #[test]
1087    fn tar_stdin_sink_summary_reports_input_not_output_tar_size() {
1088        let input = tar_stream(&[("one.txt", b"one".as_slice())]);
1089        let mut sink = MemoryArchiveSink::default();
1090
1091        let summary =
1092            write_tar_stream_archive_to_sink(&input[..], &master_key(), options(), &mut sink)
1093                .unwrap();
1094
1095        assert_eq!(summary.input_member_count, 1);
1096        assert_eq!(summary.input_tar_bytes, input.len() as u64);
1097        assert_eq!(summary.archive.volume_count, 1);
1098        assert_eq!(sink.volumes.len(), 1);
1099        open_archive(&sink.volumes[0], &master_key())
1100            .unwrap()
1101            .verify()
1102            .unwrap();
1103    }
1104
1105    #[test]
1106    fn tar_stdin_large_single_volume_round_trips() {
1107        let beta = (0..150_000)
1108            .map(|index| (index % 251) as u8)
1109            .collect::<Vec<_>>();
1110        let input = tar_stream(&[
1111            ("alpha.txt", b"alpha payload".as_slice()),
1112            ("dir/beta.bin", beta.as_slice()),
1113        ]);
1114
1115        let archive = write_tar_stream_archive(&input[..], &master_key(), options()).unwrap();
1116        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
1117
1118        opened.verify().unwrap();
1119        assert_eq!(opened.extract_file("dir/beta.bin").unwrap(), Some(beta));
1120    }
1121
1122    #[test]
1123    fn tar_stdin_multi_volume_round_trips_list_verify_and_extract() {
1124        let beta = (0..150_000)
1125            .map(|index| (index % 251) as u8)
1126            .collect::<Vec<_>>();
1127        let input = tar_stream(&[
1128            ("alpha.txt", b"alpha payload".as_slice()),
1129            ("dir/beta.bin", beta.as_slice()),
1130        ]);
1131        let mut reader = TinyReadCursor::new(input, 31);
1132        let mut sink = MemoryArchiveSink::default();
1133
1134        let summary = write_tar_stream_archive_to_sink(
1135            &mut reader,
1136            &master_key(),
1137            multi_volume_options(4),
1138            &mut sink,
1139        )
1140        .unwrap();
1141
1142        assert!(reader.reads > 10);
1143        assert_eq!(summary.input_member_count, 2);
1144        assert_eq!(summary.archive.volume_count, 4);
1145        assert_eq!(sink.volumes.len(), 4);
1146        let refs = sink.volumes.iter().map(Vec::as_slice).collect::<Vec<_>>();
1147        let opened = open_archive_volumes(&refs, &master_key()).unwrap();
1148        assert_eq!(
1149            opened.extract_file("alpha.txt").unwrap(),
1150            Some(b"alpha payload".to_vec())
1151        );
1152        assert_eq!(
1153            opened.extract_file("dir/beta.bin").unwrap(),
1154            Some(beta.clone())
1155        );
1156        opened.verify().unwrap();
1157        let listed = opened
1158            .list_files()
1159            .unwrap()
1160            .into_iter()
1161            .map(|entry| entry.path)
1162            .collect::<Vec<_>>();
1163        assert_eq!(listed, vec!["alpha.txt", "dir/beta.bin"]);
1164    }
1165
1166    #[test]
1167    fn tar_stdin_emits_identical_bytes_to_file_list_create_with_streaming_metadata_class() {
1168        let input = tar_stream(&[
1169            ("alpha.txt", b"alpha payload".as_slice()),
1170            ("dir/beta.txt", b"beta payload".as_slice()),
1171        ]);
1172        let options = single_pass_equivalence_options();
1173
1174        let streaming = write_tar_stream_archive(&input[..], &master_key(), options).unwrap();
1175        let legacy = write_archive(
1176            &[
1177                RegularFile::new("alpha.txt", b"alpha payload"),
1178                RegularFile::new("dir/beta.txt", b"beta payload"),
1179            ],
1180            &master_key(),
1181            options,
1182        )
1183        .unwrap();
1184
1185        assert_eq!(streaming.bytes, legacy.bytes);
1186        assert_eq!(streaming.bootstrap_sidecar, legacy.bootstrap_sidecar);
1187    }
1188
1189    #[test]
1190    fn tar_stdin_data_leaf_hashes_match_file_list_writer_sequence() {
1191        let input = tar_stream(&[
1192            ("alpha.txt", b"alpha payload".as_slice()),
1193            ("dir/beta.txt", b"beta payload".as_slice()),
1194        ]);
1195        let options = single_pass_equivalence_options();
1196
1197        let streaming = write_tar_stream_archive(&input[..], &master_key(), options).unwrap();
1198        let legacy = write_archive(
1199            &[
1200                RegularFile::new("alpha.txt", b"alpha payload"),
1201                RegularFile::new("dir/beta.txt", b"beta payload"),
1202            ],
1203            &master_key(),
1204            options,
1205        )
1206        .unwrap();
1207
1208        assert_eq!(
1209            data_leaf_hash_sequence(&streaming.bytes),
1210            data_leaf_hash_sequence(&legacy.bytes)
1211        );
1212    }
1213
1214    #[test]
1215    fn tar_stdin_empty_stream_accepts_two_zero_eof_blocks() {
1216        let input = vec![0u8; TAR_BLOCK_LEN * 2];
1217        let mut sink = MemoryArchiveSink::default();
1218
1219        let summary =
1220            write_tar_stream_archive_to_sink(&input[..], &master_key(), options(), &mut sink)
1221                .unwrap();
1222
1223        assert_eq!(summary.input_member_count, 0);
1224        assert_eq!(summary.input_tar_bytes, input.len() as u64);
1225        let opened = open_archive(&sink.volumes[0], &master_key()).unwrap();
1226        opened.verify().unwrap();
1227        assert!(opened.list_files().unwrap().is_empty());
1228    }
1229
1230    #[test]
1231    fn tar_stdin_accepts_base256_numeric_size_fields() {
1232        let mut header = tar_header(b"large-format.txt", b'0', 0);
1233        write_base256(&mut header[124..136], 4);
1234        header[148..156].fill(b' ');
1235        let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
1236        write_checksum(&mut header[148..156], checksum);
1237        let mut input = Vec::new();
1238        input.extend_from_slice(&header);
1239        input.extend_from_slice(b"data");
1240        input.resize(input.len() + padding_to_512(4), 0);
1241        input.extend_from_slice(&[0u8; TAR_BLOCK_LEN * 2]);
1242
1243        let archive = write_tar_stream_archive(&input[..], &master_key(), options()).unwrap();
1244        let opened = open_archive(&archive.bytes, &master_key()).unwrap();
1245
1246        assert_eq!(
1247            opened.extract_file("large-format.txt").unwrap(),
1248            Some(b"data".to_vec())
1249        );
1250    }
1251
1252    #[test]
1253    fn tar_stdin_rejects_metadata_header_without_following_member() {
1254        let payload = b"13 comment=x\n";
1255        let mut input = Vec::new();
1256        input.extend_from_slice(&tar_header(
1257            b"PaxHeaders/unused",
1258            b'x',
1259            payload.len() as u64,
1260        ));
1261        input.extend_from_slice(payload);
1262        input.resize(input.len() + padding_to_512(payload.len()), 0);
1263        input.extend_from_slice(&[0u8; TAR_BLOCK_LEN * 2]);
1264
1265        let error = write_tar_stream_archive(&input[..], &master_key(), options()).unwrap_err();
1266
1267        assert_eq!(
1268            error,
1269            FormatError::InvalidArchive("tar stream ended after metadata header")
1270        );
1271    }
1272
1273    #[test]
1274    fn tar_stdin_skips_directory_entries_and_keeps_regular_children() {
1275        let mut input = Vec::new();
1276        input.extend_from_slice(&tar_header(b"dir/", b'5', 0));
1277        input.extend_from_slice(&tar_header(b"dir/file.txt", b'0', 4));
1278        input.extend_from_slice(b"data");
1279        input.resize(input.len() + padding_to_512(4), 0);
1280        input.extend_from_slice(&[0u8; TAR_BLOCK_LEN * 2]);
1281        let mut sink = MemoryArchiveSink::default();
1282
1283        let summary =
1284            write_tar_stream_archive_to_sink(&input[..], &master_key(), options(), &mut sink)
1285                .unwrap();
1286
1287        assert_eq!(summary.input_member_count, 1);
1288        let opened = open_archive(&sink.volumes[0], &master_key()).unwrap();
1289        assert_eq!(
1290            opened
1291                .list_files()
1292                .unwrap()
1293                .into_iter()
1294                .map(|entry| entry.path)
1295                .collect::<Vec<_>>(),
1296            vec!["dir/file.txt"]
1297        );
1298    }
1299
1300    #[test]
1301    fn tar_stdin_rejects_unsupported_symlink_entries() {
1302        let mut input = Vec::new();
1303        input.extend_from_slice(&tar_header(b"link", b'2', 0));
1304        input.extend_from_slice(&[0u8; TAR_BLOCK_LEN * 2]);
1305        let mut sink = MemoryArchiveSink::default();
1306
1307        let error =
1308            write_tar_stream_archive_to_sink(&input[..], &master_key(), options(), &mut sink)
1309                .unwrap_err();
1310
1311        match error {
1312            ArchiveWriteError::Format(error) => assert_eq!(
1313                error,
1314                FormatError::WriterUnsupported(
1315                    "streaming tar stdin supports regular files and directory entries only"
1316                )
1317            ),
1318            ArchiveWriteError::Io(error) => panic!("unexpected I/O error: {error}"),
1319        }
1320        assert!(!sink.volumes.is_empty());
1321    }
1322
1323    #[test]
1324    fn tar_stdin_rejects_volume_loss_tolerance() {
1325        let mut bad = options();
1326        bad.volume_loss_tolerance = 1;
1327
1328        let error = write_tar_stream_archive(
1329            &tar_stream(&[("x", b"x".as_slice())])[..],
1330            &master_key(),
1331            bad,
1332        )
1333        .unwrap_err();
1334
1335        assert_eq!(
1336            error,
1337            FormatError::WriterUnsupported("streaming create cannot tolerate volume loss")
1338        );
1339    }
1340}