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