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