Skip to main content

tzap_core/
tar_model.rs

1use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
2use cap_std::ambient_authority;
3use cap_std::fs::{Dir as CapDir, OpenOptions as CapOpenOptions};
4use std::fs;
5use std::io::{Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime};
8
9#[cfg(unix)]
10use std::os::unix::fs::PermissionsExt;
11
12use crate::format::{ExtractError, FormatError};
13use crate::metadata::validate_file_path_bytes;
14
15const TAR_BLOCK_LEN: usize = 512;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TarEntryKind {
19    Regular,
20    Directory,
21    Symlink,
22    Hardlink,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct MetadataDiagnostic {
27    pub profile: &'static str,
28    pub message: &'static str,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OwnedTarMember {
33    pub path: Vec<u8>,
34    pub kind: TarEntryKind,
35    pub data: Vec<u8>,
36    pub link_target: Option<Vec<u8>>,
37    pub mode: u32,
38    pub mtime: u64,
39    pub logical_size: u64,
40    pub diagnostics: Vec<MetadataDiagnostic>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ParsedTarMember<'a> {
45    pub path: Vec<u8>,
46    pub kind: TarEntryKind,
47    pub data: &'a [u8],
48    pub link_target: Option<Vec<u8>>,
49    pub mode: u32,
50    pub mtime: u64,
51    pub logical_size: u64,
52    pub diagnostics: Vec<MetadataDiagnostic>,
53}
54
55impl ParsedTarMember<'_> {
56    pub fn to_owned_member(&self) -> OwnedTarMember {
57        OwnedTarMember {
58            path: self.path.clone(),
59            kind: self.kind,
60            data: self.data.to_vec(),
61            link_target: self.link_target.clone(),
62            mode: self.mode,
63            mtime: self.mtime,
64            logical_size: self.logical_size,
65            diagnostics: self.diagnostics.clone(),
66        }
67    }
68}
69
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub struct SafeExtractionOptions {
72    pub overwrite_existing: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub(crate) struct StreamedTarMemberMetadata {
77    pub path: Vec<u8>,
78    pub kind: TarEntryKind,
79    pub link_target: Option<Vec<u8>>,
80    pub mode: u32,
81    pub mtime: u64,
82    pub logical_size: u64,
83    pub diagnostics: Vec<MetadataDiagnostic>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub(crate) struct TarStreamMemberSummary {
88    pub path: Vec<u8>,
89    pub kind: TarEntryKind,
90    pub link_target: Option<Vec<u8>>,
91    pub mode: u32,
92    pub mtime: u64,
93    pub logical_size: u64,
94    pub diagnostics: Vec<MetadataDiagnostic>,
95    pub group_start: u64,
96    pub group_size: u64,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub(crate) struct TarStreamSummary {
101    pub members: Vec<TarStreamMemberSummary>,
102    pub tar_total_size: u64,
103    pub total_extraction_size: u64,
104}
105
106pub(crate) trait TarMemberGroupReader {
107    fn read_some_member_bytes(&mut self, buf: &mut [u8]) -> Result<usize, ExtractError>;
108
109    fn read_exact_member_bytes(&mut self, mut buf: &mut [u8]) -> Result<(), ExtractError> {
110        while !buf.is_empty() {
111            let read = self.read_some_member_bytes(buf)?;
112            if read == 0 {
113                return Err(
114                    FormatError::InvalidArchive("tar member group exceeds frame range").into(),
115                );
116            }
117            let (_, rest) = buf.split_at_mut(read);
118            buf = rest;
119        }
120        Ok(())
121    }
122}
123
124trait TarMemberStreamHandler {
125    fn on_member(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), ExtractError>;
126    fn write_regular_payload(&mut self, bytes: &[u8]) -> Result<(), ExtractError>;
127}
128
129pub(crate) trait TarStreamObserver {
130    fn on_member_start(&mut self, _member: &StreamedTarMemberMetadata) -> Result<(), FormatError> {
131        Ok(())
132    }
133
134    fn on_regular_payload(&mut self, _bytes: &[u8]) -> Result<(), FormatError> {
135        Ok(())
136    }
137
138    fn on_member_complete(
139        &mut self,
140        member: &StreamedTarMemberMetadata,
141    ) -> Result<Vec<MetadataDiagnostic>, FormatError> {
142        Ok(member.diagnostics.clone())
143    }
144}
145
146pub(crate) struct NoopTarStreamObserver;
147
148impl TarStreamObserver for NoopTarStreamObserver {}
149
150pub(crate) struct TarStreamFilesystemRestoreObserver<'a> {
151    handler: FilesystemRestoreHandler<'a>,
152}
153
154impl<'a> TarStreamFilesystemRestoreObserver<'a> {
155    pub(crate) fn new(root: &'a Path, options: SafeExtractionOptions) -> Self {
156        Self {
157            handler: FilesystemRestoreHandler::new(root, options),
158        }
159    }
160}
161
162impl TarStreamObserver for TarStreamFilesystemRestoreObserver<'_> {
163    fn on_member_start(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), FormatError> {
164        self.handler
165            .on_member(member)
166            .map_err(format_error_from_extract_error)
167    }
168
169    fn on_regular_payload(&mut self, bytes: &[u8]) -> Result<(), FormatError> {
170        self.handler
171            .write_regular_payload(bytes)
172            .map_err(format_error_from_extract_error)
173    }
174
175    fn on_member_complete(
176        &mut self,
177        member: &StreamedTarMemberMetadata,
178    ) -> Result<Vec<MetadataDiagnostic>, FormatError> {
179        self.handler
180            .finish(member)
181            .map_err(format_error_from_extract_error)
182    }
183}
184
185#[derive(Default)]
186struct LocalMetadata {
187    pax_path: Option<Vec<u8>>,
188    pax_linkpath: Option<Vec<u8>>,
189    pax_size: Option<u64>,
190    gnu_long_name: Option<Vec<u8>>,
191    gnu_long_link: Option<Vec<u8>>,
192    diagnostics: Vec<MetadataDiagnostic>,
193}
194
195pub fn parse_tar_member_group<'a>(
196    group: &'a [u8],
197    max_path_length: u32,
198) -> Result<ParsedTarMember<'a>, FormatError> {
199    if group.len() < TAR_BLOCK_LEN || group.len() % TAR_BLOCK_LEN != 0 {
200        return Err(FormatError::InvalidArchive(
201            "tar member group is not block aligned",
202        ));
203    }
204
205    let mut cursor = 0usize;
206    let mut metadata = LocalMetadata::default();
207
208    loop {
209        let header = slice(group, cursor, TAR_BLOCK_LEN)?;
210        if header.iter().all(|byte| *byte == 0) {
211            return Err(FormatError::InvalidArchive("tar member header is empty"));
212        }
213        verify_tar_checksum(header)?;
214        let typeflag = header[156];
215        let header_size = parse_tar_octal(&header[124..136])?;
216        let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
217        let effective_size = if is_main {
218            metadata.pax_size.unwrap_or(header_size)
219        } else {
220            header_size
221        };
222        let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
223        let payload_len = to_usize(effective_size)?;
224        let payload_end = checked_add(payload_start, payload_len)?;
225        let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
226        let payload = slice(group, payload_start, payload_len)?;
227        if padded_end > group.len() {
228            return Err(FormatError::InvalidArchive(
229                "tar member payload exceeds group",
230            ));
231        }
232        if group[payload_end..padded_end].iter().any(|byte| *byte != 0) {
233            return Err(FormatError::InvalidArchive(
234                "tar member padding is non-zero",
235            ));
236        }
237
238        match typeflag {
239            b'x' => {
240                parse_pax_records(payload, &mut metadata)?;
241                cursor = padded_end;
242            }
243            b'g' => {
244                return Err(FormatError::InvalidArchive(
245                    "global PAX headers are not allowed",
246                ));
247            }
248            b'V' | b'M' | b'N' => {
249                return Err(FormatError::InvalidArchive(
250                    "global GNU headers are not allowed",
251                ));
252            }
253            b'L' => {
254                metadata.gnu_long_name = Some(trimmed_metadata_payload(payload));
255                cursor = padded_end;
256            }
257            b'K' => {
258                metadata.gnu_long_link = Some(trimmed_metadata_payload(payload));
259                cursor = padded_end;
260            }
261            b'S' => {
262                return Err(FormatError::ReaderUnsupported(
263                    "unsupported GNU sparse tar entry",
264                ));
265            }
266            0 | b'0' | b'5' | b'2' | b'1' => {
267                if padded_end != group.len() {
268                    return Err(FormatError::InvalidArchive(
269                        "tar member group has bytes after main entry",
270                    ));
271                }
272                let kind = match typeflag {
273                    b'5' => TarEntryKind::Directory,
274                    b'2' => TarEntryKind::Symlink,
275                    b'1' => TarEntryKind::Hardlink,
276                    _ => TarEntryKind::Regular,
277                };
278                let mode = parse_tar_octal(&header[100..108])? as u32;
279                let mtime = parse_tar_octal(&header[136..148])?;
280                let path = canonical_main_path(header, kind, &metadata, max_path_length)?;
281                let link_target =
282                    canonical_link_target(header, kind, &path, &metadata, max_path_length)?;
283                if kind != TarEntryKind::Regular && effective_size != 0 {
284                    return Err(FormatError::InvalidArchive(
285                        "non-regular tar entry has non-zero payload size",
286                    ));
287                }
288                let logical_size = if kind == TarEntryKind::Regular {
289                    effective_size
290                } else {
291                    0
292                };
293                return Ok(ParsedTarMember {
294                    path,
295                    kind,
296                    data: if kind == TarEntryKind::Regular {
297                        payload
298                    } else {
299                        &[]
300                    },
301                    mode,
302                    mtime,
303                    link_target,
304                    logical_size,
305                    diagnostics: metadata.diagnostics,
306                });
307            }
308            _ => {
309                return Err(FormatError::ReaderUnsupported("unsupported tar entry type"));
310            }
311        }
312
313        if cursor >= group.len() {
314            return Err(FormatError::InvalidArchive(
315                "tar member group has metadata records but no main entry",
316            ));
317        }
318    }
319}
320
321pub fn validate_tar_stream_total_extraction_size(
322    stream: &[u8],
323    max_path_length: u32,
324    cap: u64,
325) -> Result<(), FormatError> {
326    if stream.len() % TAR_BLOCK_LEN != 0 {
327        return Err(FormatError::InvalidArchive(
328            "tar stream is not block aligned",
329        ));
330    }
331
332    let mut cursor = 0usize;
333    let mut total = 0u64;
334    while cursor < stream.len() {
335        let group_end = tar_member_group_end(stream, cursor)?;
336        let member = parse_tar_member_group(&stream[cursor..group_end], max_path_length)?;
337        if member.kind == TarEntryKind::Regular {
338            total = total
339                .checked_add(member.logical_size)
340                .ok_or(FormatError::InvalidArchive(
341                    "total extraction size overflow",
342                ))?;
343            if total > cap {
344                return Err(FormatError::ReaderUnsupported(
345                    "total extraction size exceeds configured cap",
346                ));
347            }
348        }
349        cursor = group_end;
350    }
351    Ok(())
352}
353
354pub(crate) struct TarStreamTotalExtractionSizeValidator {
355    cursor: usize,
356    total: u64,
357    max_path_length: u32,
358    cap: u64,
359}
360
361impl TarStreamTotalExtractionSizeValidator {
362    pub(crate) fn new(max_path_length: u32, cap: u64) -> Self {
363        Self {
364            cursor: 0,
365            total: 0,
366            max_path_length,
367            cap,
368        }
369    }
370
371    pub(crate) fn observe(&mut self, stream: &[u8]) -> Result<(), FormatError> {
372        while self.cursor < stream.len() {
373            let Some(group_end) = try_tar_member_group_end(stream, self.cursor)? else {
374                return Ok(());
375            };
376            let member =
377                parse_tar_member_group(&stream[self.cursor..group_end], self.max_path_length)?;
378            if member.kind == TarEntryKind::Regular {
379                self.total = self.total.checked_add(member.logical_size).ok_or(
380                    FormatError::InvalidArchive("total extraction size overflow"),
381                )?;
382                if self.total > self.cap {
383                    return Err(FormatError::ReaderUnsupported(
384                        "total extraction size exceeds configured cap",
385                    ));
386                }
387            }
388            self.cursor = group_end;
389        }
390        Ok(())
391    }
392}
393
394pub(crate) struct TarStreamSummaryValidator<O = NoopTarStreamObserver> {
395    state: StreamingTarState,
396    max_path_length: u32,
397    total_extraction_size: u64,
398    extraction_cap: u64,
399    max_metadata_payload_bytes: usize,
400    max_member_count: u64,
401    members: Vec<TarStreamMemberSummary>,
402    observer: O,
403}
404
405impl<O: TarStreamObserver> TarStreamSummaryValidator<O> {
406    pub(crate) fn with_observer(
407        max_path_length: u32,
408        extraction_cap: u64,
409        max_metadata_payload_bytes: usize,
410        max_member_count: u64,
411        observer: O,
412    ) -> Self {
413        Self {
414            state: StreamingTarState::new_member(0),
415            max_path_length,
416            total_extraction_size: 0,
417            extraction_cap,
418            max_metadata_payload_bytes,
419            max_member_count,
420            members: Vec::new(),
421            observer,
422        }
423    }
424
425    pub(crate) fn observe(&mut self, mut input: &[u8]) -> Result<(), FormatError> {
426        while !input.is_empty() {
427            let state = std::mem::replace(&mut self.state, StreamingTarState::new_member(0));
428            let (consumed, next) = self.consume_state(state, input)?;
429            self.state = self.resolve_ready_state(next)?;
430            input = &input[consumed..];
431        }
432        Ok(())
433    }
434
435    fn consume_state(
436        &mut self,
437        state: StreamingTarState,
438        input: &[u8],
439    ) -> Result<(usize, StreamingTarState), FormatError> {
440        match state {
441            StreamingTarState::Header {
442                metadata,
443                group_start,
444                mut group_size,
445                mut header,
446            } => {
447                let needed = TAR_BLOCK_LEN - header.len();
448                let take = needed.min(input.len());
449                header.extend_from_slice(&input[..take]);
450                group_size = checked_u64_add(group_size, take as u64)?;
451                checked_u64_add(group_start, group_size)?;
452                let next = if header.len() == TAR_BLOCK_LEN {
453                    let mut header_bytes = [0u8; TAR_BLOCK_LEN];
454                    header_bytes.copy_from_slice(&header);
455                    self.state_after_header(metadata, group_start, group_size, header_bytes)?
456                } else {
457                    StreamingTarState::Header {
458                        metadata,
459                        group_start,
460                        group_size,
461                        header,
462                    }
463                };
464                Ok((take, next))
465            }
466            StreamingTarState::Payload {
467                metadata,
468                group_start,
469                mut group_size,
470                mut entry,
471                mut remaining,
472                padding_remaining,
473            } => {
474                let take = remaining.min(input.len() as u64) as usize;
475                if let Some(payload) = entry.metadata_payload_mut() {
476                    let next_len = checked_add(payload.len(), take)?;
477                    if next_len > self.max_metadata_payload_bytes {
478                        return Err(FormatError::ReaderUnsupported(
479                            "tar metadata payload exceeds configured streaming cap",
480                        ));
481                    }
482                    payload.extend_from_slice(&input[..take]);
483                } else if take > 0 && entry.is_regular_main() {
484                    self.observer.on_regular_payload(&input[..take])?;
485                }
486                remaining -= take as u64;
487                group_size = checked_u64_add(group_size, take as u64)?;
488                checked_u64_add(group_start, group_size)?;
489                let next = if remaining == 0 {
490                    StreamingTarState::Padding {
491                        metadata,
492                        group_start,
493                        group_size,
494                        entry,
495                        remaining: padding_remaining,
496                    }
497                } else {
498                    StreamingTarState::Payload {
499                        metadata,
500                        group_start,
501                        group_size,
502                        entry,
503                        remaining,
504                        padding_remaining,
505                    }
506                };
507                Ok((take, next))
508            }
509            StreamingTarState::Padding {
510                metadata,
511                group_start,
512                mut group_size,
513                entry,
514                mut remaining,
515            } => {
516                let take = remaining.min(input.len() as u64) as usize;
517                if input[..take].iter().any(|byte| *byte != 0) {
518                    return Err(FormatError::InvalidArchive(
519                        "tar member padding is non-zero",
520                    ));
521                }
522                remaining -= take as u64;
523                group_size = checked_u64_add(group_size, take as u64)?;
524                checked_u64_add(group_start, group_size)?;
525                let next = if remaining == 0 {
526                    self.finish_entry_parts(metadata, group_start, group_size, entry)?
527                } else {
528                    StreamingTarState::Padding {
529                        metadata,
530                        group_start,
531                        group_size,
532                        entry,
533                        remaining,
534                    }
535                };
536                Ok((take, next))
537            }
538        }
539    }
540
541    fn resolve_ready_state(
542        &mut self,
543        mut state: StreamingTarState,
544    ) -> Result<StreamingTarState, FormatError> {
545        loop {
546            state = match state {
547                StreamingTarState::Payload {
548                    metadata,
549                    group_start,
550                    group_size,
551                    entry,
552                    remaining: 0,
553                    padding_remaining,
554                } => StreamingTarState::Padding {
555                    metadata,
556                    group_start,
557                    group_size,
558                    entry,
559                    remaining: padding_remaining,
560                },
561                StreamingTarState::Padding {
562                    metadata,
563                    group_start,
564                    group_size,
565                    entry,
566                    remaining: 0,
567                } => self.finish_entry_parts(metadata, group_start, group_size, entry)?,
568                other => return Ok(other),
569            };
570        }
571    }
572
573    pub(crate) fn tar_total_size(&self) -> u64 {
574        match &self.state {
575            StreamingTarState::Header {
576                group_start,
577                group_size,
578                ..
579            }
580            | StreamingTarState::Payload {
581                group_start,
582                group_size,
583                ..
584            }
585            | StreamingTarState::Padding {
586                group_start,
587                group_size,
588                ..
589            } => group_start + group_size,
590        }
591    }
592
593    pub(crate) fn finish(self) -> Result<TarStreamSummary, FormatError> {
594        let tar_total_size = self.tar_total_size();
595        match self.state {
596            StreamingTarState::Header {
597                header, group_size, ..
598            } if header.is_empty() && group_size == 0 => Ok(TarStreamSummary {
599                members: self.members,
600                tar_total_size,
601                total_extraction_size: self.total_extraction_size,
602            }),
603            _ => Err(FormatError::InvalidArchive(
604                "tar stream ended inside member group",
605            )),
606        }
607    }
608
609    fn state_after_header(
610        &mut self,
611        mut metadata: LocalMetadata,
612        group_start: u64,
613        group_size: u64,
614        header: [u8; TAR_BLOCK_LEN],
615    ) -> Result<StreamingTarState, FormatError> {
616        if header.iter().all(|byte| *byte == 0) {
617            return Err(FormatError::InvalidArchive("tar member header is empty"));
618        }
619        verify_tar_checksum(&header)?;
620        let typeflag = header[156];
621        let header_size = parse_tar_octal(&header[124..136])?;
622        let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
623        let effective_size = if is_main {
624            metadata.pax_size.unwrap_or(header_size)
625        } else {
626            header_size
627        };
628        let padding_remaining = padding_to_512_u64(effective_size);
629
630        let entry = match typeflag {
631            b'x' => PendingTarEntry::LocalPax {
632                payload: Vec::new(),
633            },
634            b'L' => PendingTarEntry::GnuLongName {
635                payload: Vec::new(),
636            },
637            b'K' => PendingTarEntry::GnuLongLink {
638                payload: Vec::new(),
639            },
640            b'g' => {
641                return Err(FormatError::InvalidArchive(
642                    "global PAX headers are not allowed",
643                ))
644            }
645            b'V' | b'M' | b'N' => {
646                return Err(FormatError::InvalidArchive(
647                    "global GNU headers are not allowed",
648                ))
649            }
650            b'S' => {
651                return Err(FormatError::ReaderUnsupported(
652                    "unsupported GNU sparse tar entry",
653                ))
654            }
655            0 | b'0' | b'5' | b'2' | b'1' => {
656                let kind = match typeflag {
657                    b'5' => TarEntryKind::Directory,
658                    b'2' => TarEntryKind::Symlink,
659                    b'1' => TarEntryKind::Hardlink,
660                    _ => TarEntryKind::Regular,
661                };
662                let mode = parse_tar_octal(&header[100..108])? as u32;
663                let mtime = parse_tar_octal(&header[136..148])?;
664                let path = canonical_main_path(&header, kind, &metadata, self.max_path_length)?;
665                let link_target =
666                    canonical_link_target(&header, kind, &path, &metadata, self.max_path_length)?;
667                if kind != TarEntryKind::Regular && effective_size != 0 {
668                    return Err(FormatError::InvalidArchive(
669                        "non-regular tar entry has non-zero payload size",
670                    ));
671                }
672                let logical_size = if kind == TarEntryKind::Regular {
673                    effective_size
674                } else {
675                    0
676                };
677                if kind == TarEntryKind::Regular {
678                    self.total_extraction_size =
679                        self.total_extraction_size.checked_add(logical_size).ok_or(
680                            FormatError::InvalidArchive("total extraction size overflow"),
681                        )?;
682                    if self.total_extraction_size > self.extraction_cap {
683                        return Err(FormatError::ReaderUnsupported(
684                            "total extraction size exceeds configured cap",
685                        ));
686                    }
687                }
688                let member = StreamedTarMemberMetadata {
689                    path,
690                    kind,
691                    link_target,
692                    mode,
693                    mtime,
694                    logical_size,
695                    diagnostics: std::mem::take(&mut metadata.diagnostics),
696                };
697                self.observer.on_member_start(&member)?;
698                PendingTarEntry::Main {
699                    member,
700                    group_start,
701                }
702            }
703            _ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type")),
704        };
705
706        self.resolve_ready_state(StreamingTarState::Payload {
707            metadata,
708            group_start,
709            group_size,
710            entry,
711            remaining: effective_size,
712            padding_remaining,
713        })
714    }
715
716    fn finish_entry_parts(
717        &mut self,
718        mut metadata: LocalMetadata,
719        group_start: u64,
720        group_size: u64,
721        entry: PendingTarEntry,
722    ) -> Result<StreamingTarState, FormatError> {
723        match entry {
724            PendingTarEntry::LocalPax { payload } => {
725                parse_pax_records(&payload, &mut metadata)?;
726                Ok(StreamingTarState::Header {
727                    metadata,
728                    group_start,
729                    group_size,
730                    header: Vec::new(),
731                })
732            }
733            PendingTarEntry::GnuLongName { payload } => {
734                metadata.gnu_long_name = Some(trimmed_metadata_payload(&payload));
735                Ok(StreamingTarState::Header {
736                    metadata,
737                    group_start,
738                    group_size,
739                    header: Vec::new(),
740                })
741            }
742            PendingTarEntry::GnuLongLink { payload } => {
743                metadata.gnu_long_link = Some(trimmed_metadata_payload(&payload));
744                Ok(StreamingTarState::Header {
745                    metadata,
746                    group_start,
747                    group_size,
748                    header: Vec::new(),
749                })
750            }
751            PendingTarEntry::Main {
752                member,
753                group_start,
754            } => {
755                if self.members.len() as u64 >= self.max_member_count {
756                    return Err(FormatError::ReaderUnsupported(
757                        "tar member count exceeds configured streaming cap",
758                    ));
759                }
760                let diagnostics = self.observer.on_member_complete(&member)?;
761                self.members.push(TarStreamMemberSummary {
762                    path: member.path,
763                    kind: member.kind,
764                    link_target: member.link_target,
765                    mode: member.mode,
766                    mtime: member.mtime,
767                    logical_size: member.logical_size,
768                    diagnostics,
769                    group_start,
770                    group_size,
771                });
772                Ok(StreamingTarState::new_member(checked_u64_add(
773                    group_start,
774                    group_size,
775                )?))
776            }
777        }
778    }
779}
780
781enum StreamingTarState {
782    Header {
783        metadata: LocalMetadata,
784        group_start: u64,
785        group_size: u64,
786        header: Vec<u8>,
787    },
788    Payload {
789        metadata: LocalMetadata,
790        group_start: u64,
791        group_size: u64,
792        entry: PendingTarEntry,
793        remaining: u64,
794        padding_remaining: u64,
795    },
796    Padding {
797        metadata: LocalMetadata,
798        group_start: u64,
799        group_size: u64,
800        entry: PendingTarEntry,
801        remaining: u64,
802    },
803}
804
805impl StreamingTarState {
806    fn new_member(group_start: u64) -> Self {
807        Self::Header {
808            metadata: LocalMetadata::default(),
809            group_start,
810            group_size: 0,
811            header: Vec::new(),
812        }
813    }
814}
815
816enum PendingTarEntry {
817    LocalPax {
818        payload: Vec<u8>,
819    },
820    GnuLongName {
821        payload: Vec<u8>,
822    },
823    GnuLongLink {
824        payload: Vec<u8>,
825    },
826    Main {
827        member: StreamedTarMemberMetadata,
828        group_start: u64,
829    },
830}
831
832impl PendingTarEntry {
833    fn metadata_payload_mut(&mut self) -> Option<&mut Vec<u8>> {
834        match self {
835            Self::LocalPax { payload }
836            | Self::GnuLongName { payload }
837            | Self::GnuLongLink { payload } => Some(payload),
838            Self::Main { .. } => None,
839        }
840    }
841
842    fn is_regular_main(&self) -> bool {
843        matches!(
844            self,
845            Self::Main {
846                member: StreamedTarMemberMetadata {
847                    kind: TarEntryKind::Regular,
848                    ..
849                },
850                ..
851            }
852        )
853    }
854}
855
856fn checked_u64_add(lhs: u64, rhs: u64) -> Result<u64, FormatError> {
857    lhs.checked_add(rhs).ok_or(FormatError::InvalidArchive(
858        "tar member arithmetic overflow",
859    ))
860}
861
862pub(crate) fn try_tar_member_group_end(
863    stream: &[u8],
864    start: usize,
865) -> Result<Option<usize>, FormatError> {
866    let mut cursor = start;
867    let mut metadata = LocalMetadata::default();
868
869    loop {
870        let Some(header) = try_slice(stream, cursor, TAR_BLOCK_LEN)? else {
871            return Ok(None);
872        };
873        if header.iter().all(|byte| *byte == 0) {
874            return Err(FormatError::InvalidArchive("tar member header is empty"));
875        }
876        verify_tar_checksum(header)?;
877        let typeflag = header[156];
878        let header_size = parse_tar_octal(&header[124..136])?;
879        let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
880        let effective_size = if is_main {
881            metadata.pax_size.unwrap_or(header_size)
882        } else {
883            header_size
884        };
885        let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
886        let payload_len = to_usize(effective_size)?;
887        let payload_end = checked_add(payload_start, payload_len)?;
888        let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
889        let Some(payload) = try_slice(stream, payload_start, payload_len)? else {
890            return Ok(None);
891        };
892        if padded_end > stream.len() {
893            return Ok(None);
894        }
895        if stream[payload_end..padded_end]
896            .iter()
897            .any(|byte| *byte != 0)
898        {
899            return Err(FormatError::InvalidArchive(
900                "tar member padding is non-zero",
901            ));
902        }
903
904        match typeflag {
905            b'x' => {
906                parse_pax_records(payload, &mut metadata)?;
907                cursor = padded_end;
908            }
909            b'L' | b'K' => {
910                cursor = padded_end;
911            }
912            b'g' => {
913                return Err(FormatError::InvalidArchive(
914                    "global PAX headers are not allowed",
915                ));
916            }
917            b'V' | b'M' | b'N' => {
918                return Err(FormatError::InvalidArchive(
919                    "global GNU headers are not allowed",
920                ));
921            }
922            b'S' => {
923                return Err(FormatError::ReaderUnsupported(
924                    "unsupported GNU sparse tar entry",
925                ));
926            }
927            0 | b'0' | b'5' | b'2' | b'1' => return Ok(Some(padded_end)),
928            _ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type")),
929        }
930
931        if cursor >= stream.len() {
932            return Ok(None);
933        }
934    }
935}
936
937fn try_slice(stream: &[u8], offset: usize, len: usize) -> Result<Option<&[u8]>, FormatError> {
938    let end = checked_add(offset, len)?;
939    if end > stream.len() {
940        return Ok(None);
941    }
942    Ok(Some(&stream[offset..end]))
943}
944
945pub(crate) fn stream_regular_tar_member_group_to_writer<R, W>(
946    reader: &mut R,
947    expected_path: &[u8],
948    expected_file_data_size: u64,
949    group_len: u64,
950    max_path_length: u32,
951    writer: &mut W,
952) -> Result<Vec<MetadataDiagnostic>, ExtractError>
953where
954    R: TarMemberGroupReader,
955    W: Write,
956{
957    let mut handler = RegularWriterHandler { writer };
958    let member = stream_tar_member_group(
959        reader,
960        expected_path,
961        expected_file_data_size,
962        group_len,
963        max_path_length,
964        &mut handler,
965    )?;
966    Ok(member.diagnostics)
967}
968
969pub(crate) fn restore_streaming_tar_member_group<R>(
970    root: &Path,
971    expected_path: &[u8],
972    expected_file_data_size: u64,
973    group_len: u64,
974    max_path_length: u32,
975    options: SafeExtractionOptions,
976    reader: &mut R,
977) -> Result<Vec<MetadataDiagnostic>, ExtractError>
978where
979    R: TarMemberGroupReader,
980{
981    let mut handler = FilesystemRestoreHandler::new(root, options);
982    let member = stream_tar_member_group(
983        reader,
984        expected_path,
985        expected_file_data_size,
986        group_len,
987        max_path_length,
988        &mut handler,
989    )?;
990    handler.finish(&member)
991}
992
993fn stream_tar_member_group<R, H>(
994    reader: &mut R,
995    expected_path: &[u8],
996    expected_file_data_size: u64,
997    group_len: u64,
998    max_path_length: u32,
999    handler: &mut H,
1000) -> Result<StreamedTarMemberMetadata, ExtractError>
1001where
1002    R: TarMemberGroupReader,
1003    H: TarMemberStreamHandler,
1004{
1005    if group_len < TAR_BLOCK_LEN as u64 || group_len % TAR_BLOCK_LEN as u64 != 0 {
1006        return Err(FormatError::InvalidArchive("tar member group is not block aligned").into());
1007    }
1008
1009    let mut remaining = group_len;
1010    let mut metadata = LocalMetadata::default();
1011
1012    loop {
1013        let mut header = [0u8; TAR_BLOCK_LEN];
1014        read_member_bytes(reader, &mut header, &mut remaining)?;
1015        if header.iter().all(|byte| *byte == 0) {
1016            return Err(FormatError::InvalidArchive("tar member header is empty").into());
1017        }
1018        verify_tar_checksum(&header)?;
1019
1020        let typeflag = header[156];
1021        let header_size = parse_tar_octal(&header[124..136])?;
1022        let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
1023        let effective_size = if is_main {
1024            metadata.pax_size.unwrap_or(header_size)
1025        } else {
1026            header_size
1027        };
1028        let padding_len = padding_to_512_u64(effective_size);
1029        let entry_payload_len =
1030            effective_size
1031                .checked_add(padding_len)
1032                .ok_or(FormatError::InvalidArchive(
1033                    "tar member arithmetic overflow",
1034                ))?;
1035        if entry_payload_len > remaining {
1036            return Err(FormatError::InvalidArchive("tar member payload exceeds group").into());
1037        }
1038
1039        match typeflag {
1040            b'x' => {
1041                let payload = read_member_vec(reader, effective_size, &mut remaining)?;
1042                parse_pax_records(&payload, &mut metadata)?;
1043                read_zero_padding(reader, padding_len, &mut remaining)?;
1044            }
1045            b'g' => {
1046                return Err(
1047                    FormatError::InvalidArchive("global PAX headers are not allowed").into(),
1048                );
1049            }
1050            b'V' | b'M' | b'N' => {
1051                return Err(
1052                    FormatError::InvalidArchive("global GNU headers are not allowed").into(),
1053                );
1054            }
1055            b'L' => {
1056                let payload = read_member_vec(reader, effective_size, &mut remaining)?;
1057                metadata.gnu_long_name = Some(trimmed_metadata_payload(&payload));
1058                read_zero_padding(reader, padding_len, &mut remaining)?;
1059            }
1060            b'K' => {
1061                let payload = read_member_vec(reader, effective_size, &mut remaining)?;
1062                metadata.gnu_long_link = Some(trimmed_metadata_payload(&payload));
1063                read_zero_padding(reader, padding_len, &mut remaining)?;
1064            }
1065            b'S' => {
1066                return Err(
1067                    FormatError::ReaderUnsupported("unsupported GNU sparse tar entry").into(),
1068                );
1069            }
1070            0 | b'0' | b'5' | b'2' | b'1' => {
1071                let kind = match typeflag {
1072                    b'5' => TarEntryKind::Directory,
1073                    b'2' => TarEntryKind::Symlink,
1074                    b'1' => TarEntryKind::Hardlink,
1075                    _ => TarEntryKind::Regular,
1076                };
1077                let mode = parse_tar_octal(&header[100..108])? as u32;
1078                let mtime = parse_tar_octal(&header[136..148])?;
1079                let path = canonical_main_path(&header, kind, &metadata, max_path_length)?;
1080                let link_target =
1081                    canonical_link_target(&header, kind, &path, &metadata, max_path_length)?;
1082                if kind != TarEntryKind::Regular && effective_size != 0 {
1083                    return Err(FormatError::InvalidArchive(
1084                        "non-regular tar entry has non-zero payload size",
1085                    )
1086                    .into());
1087                }
1088                let logical_size = if kind == TarEntryKind::Regular {
1089                    effective_size
1090                } else {
1091                    0
1092                };
1093                let member = StreamedTarMemberMetadata {
1094                    path,
1095                    kind,
1096                    link_target,
1097                    mode,
1098                    mtime,
1099                    logical_size,
1100                    diagnostics: std::mem::take(&mut metadata.diagnostics),
1101                };
1102                if member.path != expected_path {
1103                    return Err(FormatError::InvalidArchive(
1104                        "tar member path does not match FileEntry path",
1105                    )
1106                    .into());
1107                }
1108                if member.logical_size != expected_file_data_size {
1109                    return Err(FormatError::InvalidArchive(
1110                        "tar member size does not match FileEntry file_data_size",
1111                    )
1112                    .into());
1113                }
1114                handler.on_member(&member)?;
1115                if member.kind == TarEntryKind::Regular {
1116                    stream_regular_payload(reader, effective_size, &mut remaining, handler)?;
1117                }
1118                read_zero_padding(reader, padding_len, &mut remaining)?;
1119                if remaining != 0 {
1120                    return Err(FormatError::InvalidArchive(
1121                        "tar member group has bytes after main entry",
1122                    )
1123                    .into());
1124                }
1125                return Ok(member);
1126            }
1127            _ => {
1128                return Err(FormatError::ReaderUnsupported("unsupported tar entry type").into());
1129            }
1130        }
1131
1132        if remaining == 0 {
1133            return Err(FormatError::InvalidArchive(
1134                "tar member group has metadata records but no main entry",
1135            )
1136            .into());
1137        }
1138    }
1139}
1140
1141struct RegularWriterHandler<'a, W> {
1142    writer: &'a mut W,
1143}
1144
1145impl<W: Write> TarMemberStreamHandler for RegularWriterHandler<'_, W> {
1146    fn on_member(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), ExtractError> {
1147        if member.kind != TarEntryKind::Regular {
1148            return Err(FormatError::ReaderUnsupported(
1149                "extract_file_to_writer returns only regular file payloads",
1150            )
1151            .into());
1152        }
1153        Ok(())
1154    }
1155
1156    fn write_regular_payload(&mut self, bytes: &[u8]) -> Result<(), ExtractError> {
1157        self.writer.write_all(bytes).map_err(ExtractError::Output)
1158    }
1159}
1160
1161struct FilesystemRestoreHandler<'a> {
1162    root: &'a Path,
1163    options: SafeExtractionOptions,
1164    destination: Option<PreparedDestination>,
1165    temp_leaf: Option<PathBuf>,
1166    file: Option<fs::File>,
1167}
1168
1169impl<'a> FilesystemRestoreHandler<'a> {
1170    fn new(root: &'a Path, options: SafeExtractionOptions) -> Self {
1171        Self {
1172            root,
1173            options,
1174            destination: None,
1175            temp_leaf: None,
1176            file: None,
1177        }
1178    }
1179
1180    fn finish(
1181        &mut self,
1182        member: &StreamedTarMemberMetadata,
1183    ) -> Result<Vec<MetadataDiagnostic>, ExtractError> {
1184        let mut diagnostics = member.diagnostics.clone();
1185        if member.kind != TarEntryKind::Regular {
1186            return Ok(diagnostics);
1187        }
1188
1189        let mut file = self.file.take().ok_or(FormatError::InvalidArchive(
1190            "regular file output is missing",
1191        ))?;
1192        file.flush()
1193            .map_err(|_| FormatError::FilesystemExtractionFailed("failed to write regular file"))?;
1194
1195        let destination = self.destination.take().ok_or(FormatError::InvalidArchive(
1196            "regular file destination is missing",
1197        ))?;
1198        let temp_leaf = self.temp_leaf.take().ok_or(FormatError::InvalidArchive(
1199            "regular file temp path is missing",
1200        ))?;
1201        let file = publish_regular_file(&destination, &temp_leaf, file, self.options)?;
1202        apply_restored_regular_file_metadata_parts(
1203            &file,
1204            member.mode,
1205            member.mtime,
1206            &mut diagnostics,
1207        );
1208        Ok(diagnostics)
1209    }
1210}
1211
1212impl Drop for FilesystemRestoreHandler<'_> {
1213    fn drop(&mut self) {
1214        if let (Some(destination), Some(temp_leaf)) =
1215            (self.destination.as_ref(), self.temp_leaf.take())
1216        {
1217            let _ = destination.parent.remove_file_or_symlink(temp_leaf);
1218        }
1219    }
1220}
1221
1222impl TarMemberStreamHandler for FilesystemRestoreHandler<'_> {
1223    fn on_member(&mut self, member: &StreamedTarMemberMetadata) -> Result<(), ExtractError> {
1224        let destination = prepare_destination(self.root, &member.path, member.kind, self.options)?;
1225        match member.kind {
1226            TarEntryKind::Regular => {
1227                let (temp_leaf, file) = create_temp_regular_file(&destination)?;
1228                self.destination = Some(destination);
1229                self.temp_leaf = Some(temp_leaf);
1230                self.file = Some(file);
1231            }
1232            TarEntryKind::Directory => create_directory(&destination)?,
1233            TarEntryKind::Symlink => {
1234                let target = member
1235                    .link_target
1236                    .as_deref()
1237                    .ok_or(FormatError::InvalidArchive("symlink target is missing"))?;
1238                validate_symlink_target(&member.path, target)?;
1239                create_symlink(&destination, target, self.options)?;
1240            }
1241            TarEntryKind::Hardlink => {
1242                let target = member
1243                    .link_target
1244                    .as_deref()
1245                    .ok_or(FormatError::InvalidArchive("hardlink target is missing"))?;
1246                let target_path = existing_safe_regular_path(self.root, target)?;
1247                create_hardlink(&destination, &target_path, self.options)?;
1248            }
1249        }
1250        Ok(())
1251    }
1252
1253    fn write_regular_payload(&mut self, bytes: &[u8]) -> Result<(), ExtractError> {
1254        let file = self.file.as_mut().ok_or(FormatError::InvalidArchive(
1255            "regular file output is missing",
1256        ))?;
1257        file.write_all(bytes)
1258            .map_err(|_| FormatError::FilesystemExtractionFailed("failed to write regular file"))?;
1259        Ok(())
1260    }
1261}
1262
1263fn format_error_from_extract_error(error: ExtractError) -> FormatError {
1264    match error {
1265        ExtractError::Format(error) => error,
1266        ExtractError::Output(_) => {
1267            FormatError::FilesystemExtractionFailed("failed to write regular file")
1268        }
1269    }
1270}
1271
1272fn read_member_bytes<R: TarMemberGroupReader>(
1273    reader: &mut R,
1274    buf: &mut [u8],
1275    remaining: &mut u64,
1276) -> Result<(), ExtractError> {
1277    if buf.len() as u64 > *remaining {
1278        return Err(FormatError::InvalidArchive("tar member payload exceeds group").into());
1279    }
1280    reader.read_exact_member_bytes(buf)?;
1281    *remaining -= buf.len() as u64;
1282    Ok(())
1283}
1284
1285fn read_member_vec<R: TarMemberGroupReader>(
1286    reader: &mut R,
1287    len: u64,
1288    remaining: &mut u64,
1289) -> Result<Vec<u8>, ExtractError> {
1290    let mut out = vec![0u8; to_usize(len)?];
1291    read_member_bytes(reader, &mut out, remaining)?;
1292    Ok(out)
1293}
1294
1295fn read_zero_padding<R: TarMemberGroupReader>(
1296    reader: &mut R,
1297    len: u64,
1298    remaining: &mut u64,
1299) -> Result<(), ExtractError> {
1300    let mut pending = len;
1301    let mut buf = [0u8; 8192];
1302    while pending > 0 {
1303        let chunk_len = pending.min(buf.len() as u64) as usize;
1304        read_member_bytes(reader, &mut buf[..chunk_len], remaining)?;
1305        if buf[..chunk_len].iter().any(|byte| *byte != 0) {
1306            return Err(FormatError::InvalidArchive("tar member padding is non-zero").into());
1307        }
1308        pending -= chunk_len as u64;
1309    }
1310    Ok(())
1311}
1312
1313fn stream_regular_payload<R, H>(
1314    reader: &mut R,
1315    len: u64,
1316    remaining: &mut u64,
1317    handler: &mut H,
1318) -> Result<(), ExtractError>
1319where
1320    R: TarMemberGroupReader,
1321    H: TarMemberStreamHandler,
1322{
1323    let mut pending = len;
1324    let mut buf = [0u8; 64 * 1024];
1325    while pending > 0 {
1326        let chunk_len = pending.min(buf.len() as u64).min(*remaining) as usize;
1327        let read = reader.read_some_member_bytes(&mut buf[..chunk_len])?;
1328        if read == 0 {
1329            return Err(FormatError::InvalidArchive("tar member group exceeds frame range").into());
1330        }
1331        *remaining -= read as u64;
1332        pending -= read as u64;
1333        handler.write_regular_payload(&buf[..read])?;
1334    }
1335    Ok(())
1336}
1337
1338fn tar_member_group_end(stream: &[u8], start: usize) -> Result<usize, FormatError> {
1339    let mut cursor = start;
1340    let mut metadata = LocalMetadata::default();
1341
1342    loop {
1343        let header = slice(stream, cursor, TAR_BLOCK_LEN)?;
1344        if header.iter().all(|byte| *byte == 0) {
1345            return Err(FormatError::InvalidArchive("tar member header is empty"));
1346        }
1347        verify_tar_checksum(header)?;
1348        let typeflag = header[156];
1349        let header_size = parse_tar_octal(&header[124..136])?;
1350        let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
1351        let effective_size = if is_main {
1352            metadata.pax_size.unwrap_or(header_size)
1353        } else {
1354            header_size
1355        };
1356        let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
1357        let payload_len = to_usize(effective_size)?;
1358        let payload_end = checked_add(payload_start, payload_len)?;
1359        let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
1360        let payload = slice(stream, payload_start, payload_len)?;
1361        if padded_end > stream.len() {
1362            return Err(FormatError::InvalidArchive(
1363                "tar member payload exceeds stream",
1364            ));
1365        }
1366        if stream[payload_end..padded_end]
1367            .iter()
1368            .any(|byte| *byte != 0)
1369        {
1370            return Err(FormatError::InvalidArchive(
1371                "tar member padding is non-zero",
1372            ));
1373        }
1374
1375        match typeflag {
1376            b'x' => {
1377                parse_pax_records(payload, &mut metadata)?;
1378                cursor = padded_end;
1379            }
1380            b'L' | b'K' => {
1381                cursor = padded_end;
1382            }
1383            b'g' => {
1384                return Err(FormatError::InvalidArchive(
1385                    "global PAX headers are not allowed",
1386                ));
1387            }
1388            b'V' | b'M' | b'N' => {
1389                return Err(FormatError::InvalidArchive(
1390                    "global GNU headers are not allowed",
1391                ));
1392            }
1393            b'S' => {
1394                return Err(FormatError::ReaderUnsupported(
1395                    "unsupported GNU sparse tar entry",
1396                ));
1397            }
1398            0 | b'0' | b'5' | b'2' | b'1' => return Ok(padded_end),
1399            _ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type")),
1400        }
1401
1402        if cursor >= stream.len() {
1403            return Err(FormatError::InvalidArchive(
1404                "tar member group has metadata records but no main entry",
1405            ));
1406        }
1407    }
1408}
1409
1410pub fn restore_tar_member(
1411    root: &Path,
1412    member: &OwnedTarMember,
1413    options: SafeExtractionOptions,
1414) -> Result<Vec<MetadataDiagnostic>, FormatError> {
1415    let destination = prepare_destination(root, &member.path, member.kind, options)?;
1416    let mut diagnostics = member.diagnostics.clone();
1417    match member.kind {
1418        TarEntryKind::Regular => {
1419            let (temp_leaf, mut file) = create_temp_regular_file(&destination)?;
1420            file.write_all(&member.data).map_err(|_| {
1421                FormatError::FilesystemExtractionFailed("failed to write regular file")
1422            })?;
1423            file.flush().map_err(|_| {
1424                FormatError::FilesystemExtractionFailed("failed to write regular file")
1425            })?;
1426            let file = publish_regular_file(&destination, &temp_leaf, file, options)?;
1427            apply_restored_regular_file_metadata(&file, member, &mut diagnostics);
1428        }
1429        TarEntryKind::Directory => create_directory(&destination)?,
1430        TarEntryKind::Symlink => {
1431            let target = member
1432                .link_target
1433                .as_deref()
1434                .ok_or(FormatError::InvalidArchive("symlink target is missing"))?;
1435            validate_symlink_target(&member.path, target)?;
1436            create_symlink(&destination, target, options)?;
1437        }
1438        TarEntryKind::Hardlink => {
1439            let target = member
1440                .link_target
1441                .as_deref()
1442                .ok_or(FormatError::InvalidArchive("hardlink target is missing"))?;
1443            let target_path = existing_safe_regular_path(root, target)?;
1444            create_hardlink(&destination, &target_path, options)?;
1445        }
1446    }
1447    Ok(diagnostics)
1448}
1449
1450fn apply_restored_regular_file_metadata(
1451    file: &fs::File,
1452    member: &OwnedTarMember,
1453    diagnostics: &mut Vec<MetadataDiagnostic>,
1454) {
1455    apply_restored_regular_file_metadata_parts(file, member.mode, member.mtime, diagnostics);
1456}
1457
1458fn apply_restored_regular_file_metadata_parts(
1459    file: &fs::File,
1460    mode: u32,
1461    mtime: u64,
1462    diagnostics: &mut Vec<MetadataDiagnostic>,
1463) {
1464    apply_regular_file_mtime(file, mtime, diagnostics);
1465    apply_regular_file_mode(file, mode, diagnostics);
1466}
1467
1468#[cfg(unix)]
1469fn apply_regular_file_mode(file: &fs::File, mode: u32, diagnostics: &mut Vec<MetadataDiagnostic>) {
1470    match file.metadata() {
1471        Ok(metadata) => {
1472            let mut permissions = metadata.permissions();
1473            permissions.set_mode(mode & 0o7777);
1474            if file.set_permissions(permissions).is_err() {
1475                diagnostics.push(MetadataDiagnostic {
1476                    profile: "ustar-baseline",
1477                    message: "failed to apply mode metadata",
1478                });
1479            }
1480        }
1481        Err(_) => diagnostics.push(MetadataDiagnostic {
1482            profile: "ustar-baseline",
1483            message: "failed to apply mode metadata",
1484        }),
1485    }
1486}
1487
1488#[cfg(not(unix))]
1489fn apply_regular_file_mode(file: &fs::File, mode: u32, diagnostics: &mut Vec<MetadataDiagnostic>) {
1490    match file.metadata() {
1491        Ok(metadata) => {
1492            let mut permissions = metadata.permissions();
1493            permissions.set_readonly(mode & 0o222 == 0);
1494            if file.set_permissions(permissions).is_err() {
1495                diagnostics.push(MetadataDiagnostic {
1496                    profile: "ustar-baseline",
1497                    message: "failed to apply mode metadata",
1498                });
1499                return;
1500            }
1501            if mode & 0o777 != 0o444 && mode & 0o777 != 0o666 {
1502                diagnostics.push(MetadataDiagnostic {
1503                    profile: "ustar-baseline",
1504                    message: "mode metadata was only partially applied on this platform",
1505                });
1506            }
1507        }
1508        Err(_) => diagnostics.push(MetadataDiagnostic {
1509            profile: "ustar-baseline",
1510            message: "failed to apply mode metadata",
1511        }),
1512    }
1513}
1514
1515fn apply_regular_file_mtime(
1516    file: &fs::File,
1517    mtime: u64,
1518    diagnostics: &mut Vec<MetadataDiagnostic>,
1519) {
1520    let Some(modified) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(mtime)) else {
1521        diagnostics.push(MetadataDiagnostic {
1522            profile: "ustar-baseline",
1523            message: "failed to apply mtime metadata",
1524        });
1525        return;
1526    };
1527    let times = fs::FileTimes::new().set_modified(modified);
1528    if file.set_times(times).is_err() {
1529        diagnostics.push(MetadataDiagnostic {
1530            profile: "ustar-baseline",
1531            message: "failed to apply mtime metadata",
1532        });
1533    }
1534}
1535
1536fn canonical_main_path(
1537    header: &[u8],
1538    kind: TarEntryKind,
1539    metadata: &LocalMetadata,
1540    max_path_length: u32,
1541) -> Result<Vec<u8>, FormatError> {
1542    let mut path = metadata
1543        .pax_path
1544        .clone()
1545        .or_else(|| metadata.gnu_long_name.clone())
1546        .unwrap_or_else(|| ustar_path(header));
1547    if kind == TarEntryKind::Directory && path.ends_with(b"/") && !path.ends_with(b"//") {
1548        path.pop();
1549    }
1550    validate_file_path_bytes(&path, max_path_length)?;
1551    Ok(path)
1552}
1553
1554fn canonical_link_target(
1555    header: &[u8],
1556    kind: TarEntryKind,
1557    link_path: &[u8],
1558    metadata: &LocalMetadata,
1559    max_path_length: u32,
1560) -> Result<Option<Vec<u8>>, FormatError> {
1561    if !matches!(kind, TarEntryKind::Symlink | TarEntryKind::Hardlink) {
1562        return Ok(None);
1563    }
1564    let target = metadata
1565        .pax_linkpath
1566        .clone()
1567        .or_else(|| metadata.gnu_long_link.clone())
1568        .unwrap_or_else(|| nul_trimmed(&header[157..257]).to_vec());
1569    if target.is_empty() {
1570        return Err(FormatError::UnsafeArchivePath);
1571    }
1572    match kind {
1573        TarEntryKind::Hardlink => validate_file_path_bytes(&target, max_path_length)?,
1574        TarEntryKind::Symlink => validate_symlink_target(link_path, &target)?,
1575        _ => {}
1576    }
1577    Ok(Some(target))
1578}
1579
1580fn parse_pax_records(payload: &[u8], metadata: &mut LocalMetadata) -> Result<(), FormatError> {
1581    let mut cursor = 0usize;
1582    while cursor < payload.len() {
1583        let len_digits_start = cursor;
1584        while cursor < payload.len() && payload[cursor].is_ascii_digit() {
1585            cursor += 1;
1586        }
1587        if cursor == len_digits_start || cursor >= payload.len() || payload[cursor] != b' ' {
1588            return Err(FormatError::InvalidArchive("malformed PAX record"));
1589        }
1590        let len = parse_decimal(&payload[len_digits_start..cursor])?;
1591        let record_start = len_digits_start;
1592        let record_end = checked_add(record_start, len)?;
1593        if record_end > payload.len() || len < 4 {
1594            return Err(FormatError::InvalidArchive("malformed PAX record"));
1595        }
1596        let body_start = cursor + 1;
1597        let record = &payload[body_start..record_end];
1598        if record.last().copied() != Some(b'\n') {
1599            return Err(FormatError::InvalidArchive("malformed PAX record"));
1600        }
1601        let body = &record[..record.len() - 1];
1602        let eq = body
1603            .iter()
1604            .position(|byte| *byte == b'=')
1605            .ok_or(FormatError::InvalidArchive("malformed PAX record"))?;
1606        let key = std::str::from_utf8(&body[..eq])
1607            .map_err(|_| FormatError::InvalidArchive("malformed PAX key"))?;
1608        let value = &body[eq + 1..];
1609        match key {
1610            "path" => metadata.pax_path = Some(value.to_vec()),
1611            "linkpath" => metadata.pax_linkpath = Some(value.to_vec()),
1612            "size" => metadata.pax_size = Some(parse_decimal(value)? as u64),
1613            "atime" | "ctime" | "mtime" => metadata.diagnostics.push(MetadataDiagnostic {
1614                profile: "pax-posix-2001",
1615                message: "unsupported PAX timestamp metadata was ignored",
1616            }),
1617            key if key.starts_with("SCHILY.xattr.")
1618                || key.starts_with("LIBARCHIVE.xattr.")
1619                || key.starts_with("SCHILY.acl.")
1620                || key.starts_with("LIBARCHIVE.acl.") =>
1621            {
1622                metadata.diagnostics.push(MetadataDiagnostic {
1623                    profile: "pax-xattrs-acls",
1624                    message: "unsupported xattr/ACL PAX metadata was ignored",
1625                });
1626            }
1627            key if key.starts_with("GNU.sparse.") => {
1628                metadata.diagnostics.push(MetadataDiagnostic {
1629                    profile: "gnu-sparse",
1630                    message: "unsupported sparse-file PAX metadata was ignored",
1631                });
1632            }
1633            _ => metadata.diagnostics.push(MetadataDiagnostic {
1634                profile: "pax-posix-2001",
1635                message: "unsupported PAX key was ignored",
1636            }),
1637        }
1638        cursor = record_end;
1639    }
1640    Ok(())
1641}
1642
1643fn validate_symlink_target(link_path: &[u8], target: &[u8]) -> Result<(), FormatError> {
1644    if target.is_empty()
1645        || target.contains(&0)
1646        || target.contains(&b'\\')
1647        || target.contains(&b':')
1648        || target[0] == b'/'
1649    {
1650        return Err(FormatError::UnsafeArchivePath);
1651    }
1652    let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
1653    let link_path = std::str::from_utf8(link_path).map_err(|_| FormatError::UnsafeArchivePath)?;
1654    let mut stack = link_path
1655        .split('/')
1656        .take(link_path.split('/').count().saturating_sub(1))
1657        .map(str::to_owned)
1658        .collect::<Vec<_>>();
1659    for component in target.split('/') {
1660        if component.is_empty() || component == "." {
1661            return Err(FormatError::UnsafeArchivePath);
1662        }
1663        if component == ".." {
1664            if stack.pop().is_none() {
1665                return Err(FormatError::UnsafeArchivePath);
1666            }
1667        } else {
1668            validate_file_path_bytes(component.as_bytes(), u32::MAX)?;
1669            stack.push(component.to_owned());
1670        }
1671    }
1672    Ok(())
1673}
1674
1675struct PreparedDestination {
1676    parent: CapDir,
1677    leaf: PathBuf,
1678}
1679
1680fn prepare_destination(
1681    root: &Path,
1682    archive_path: &[u8],
1683    kind: TarEntryKind,
1684    options: SafeExtractionOptions,
1685) -> Result<PreparedDestination, FormatError> {
1686    let components = path_components(archive_path)?;
1687    let mut parent = open_extraction_root(root)?;
1688    for component in &components[..components.len().saturating_sub(1)] {
1689        parent = open_or_create_safe_child_dir(&parent, component)?;
1690    }
1691
1692    let leaf = PathBuf::from(components.last().ok_or(FormatError::UnsafeArchivePath)?);
1693    match parent.symlink_metadata(&leaf) {
1694        Ok(metadata) => {
1695            let file_type = metadata.file_type();
1696            if file_type.is_symlink() {
1697                return Err(FormatError::UnsafeArchivePath);
1698            }
1699            if kind == TarEntryKind::Directory {
1700                if file_type.is_dir() {
1701                    return Ok(PreparedDestination { parent, leaf });
1702                }
1703                return Err(FormatError::UnsafeOverwrite);
1704            }
1705            if file_type.is_dir() {
1706                return Err(FormatError::UnsafeOverwrite);
1707            }
1708            if !options.overwrite_existing {
1709                return Err(FormatError::UnsafeOverwrite);
1710            }
1711        }
1712        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1713        Err(_) => {
1714            return Err(FormatError::FilesystemExtractionFailed(
1715                "failed to inspect destination",
1716            ));
1717        }
1718    }
1719    Ok(PreparedDestination { parent, leaf })
1720}
1721
1722fn open_extraction_root(root: &Path) -> Result<CapDir, FormatError> {
1723    let metadata = fs::symlink_metadata(root).map_err(|_| {
1724        FormatError::FilesystemExtractionFailed("extraction root must already exist")
1725    })?;
1726    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
1727        return Err(FormatError::UnsafeArchivePath);
1728    }
1729    CapDir::open_ambient_dir(root, ambient_authority())
1730        .map_err(|_| FormatError::FilesystemExtractionFailed("extraction root must already exist"))
1731}
1732
1733fn open_or_create_safe_child_dir(parent: &CapDir, component: &str) -> Result<CapDir, FormatError> {
1734    match parent.open_dir_nofollow(component) {
1735        Ok(child) => return Ok(child),
1736        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1737        Err(_) => return Err(FormatError::UnsafeArchivePath),
1738    }
1739
1740    match parent.create_dir(component) {
1741        Ok(()) => {}
1742        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1743        Err(_) => {
1744            return Err(FormatError::FilesystemExtractionFailed(
1745                "failed to create parent directory",
1746            ));
1747        }
1748    }
1749    parent
1750        .open_dir_nofollow(component)
1751        .map_err(|_| FormatError::UnsafeArchivePath)
1752}
1753
1754fn existing_safe_regular_path(
1755    root: &Path,
1756    archive_path: &[u8],
1757) -> Result<PreparedDestination, FormatError> {
1758    validate_file_path_bytes(archive_path, u32::MAX)?;
1759    let components = path_components(archive_path)?;
1760    let mut parent = open_extraction_root(root)?;
1761    for component in &components[..components.len().saturating_sub(1)] {
1762        parent = parent
1763            .open_dir_nofollow(component)
1764            .map_err(|_| FormatError::UnsafeArchivePath)?;
1765    }
1766
1767    let leaf = PathBuf::from(components.last().ok_or(FormatError::UnsafeArchivePath)?);
1768    let metadata = parent
1769        .symlink_metadata(&leaf)
1770        .map_err(|_| FormatError::UnsafeArchivePath)?;
1771    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
1772        return Err(FormatError::UnsafeArchivePath);
1773    }
1774    Ok(PreparedDestination { parent, leaf })
1775}
1776
1777fn create_new_file_options() -> CapOpenOptions {
1778    let mut options = CapOpenOptions::new();
1779    options
1780        .read(true)
1781        .write(true)
1782        .create_new(true)
1783        .follow(FollowSymlinks::No);
1784    options
1785}
1786
1787fn create_temp_regular_file(
1788    destination: &PreparedDestination,
1789) -> Result<(PathBuf, fs::File), FormatError> {
1790    for _ in 0..1000u32 {
1791        let mut candidate = destination.leaf.as_os_str().to_os_string();
1792        candidate.push(format!(".tzap-tmp-{}", uuid::Uuid::new_v4()));
1793        let leaf = PathBuf::from(candidate);
1794        match destination
1795            .parent
1796            .open_with(&leaf, &create_new_file_options())
1797        {
1798            Ok(file) => return Ok((leaf, file.into_std())),
1799            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1800            Err(_) => {
1801                return Err(FormatError::FilesystemExtractionFailed(
1802                    "failed to create regular file",
1803                ));
1804            }
1805        }
1806    }
1807    Err(FormatError::FilesystemExtractionFailed(
1808        "failed to create regular file",
1809    ))
1810}
1811
1812fn publish_regular_file(
1813    destination: &PreparedDestination,
1814    temp_leaf: &Path,
1815    mut temp_file: fs::File,
1816    options: SafeExtractionOptions,
1817) -> Result<fs::File, FormatError> {
1818    if options.overwrite_existing {
1819        remove_existing_leaf_if_needed(destination)?;
1820    }
1821
1822    let mut output = match destination
1823        .parent
1824        .open_with(&destination.leaf, &create_new_file_options())
1825    {
1826        Ok(file) => file.into_std(),
1827        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1828            let _ = destination.parent.remove_file_or_symlink(temp_leaf);
1829            return Err(FormatError::UnsafeOverwrite);
1830        }
1831        Err(_) => {
1832            let _ = destination.parent.remove_file_or_symlink(temp_leaf);
1833            return Err(FormatError::FilesystemExtractionFailed(
1834                "failed to create regular file",
1835            ));
1836        }
1837    };
1838
1839    let copy_result = temp_file
1840        .seek(SeekFrom::Start(0))
1841        .and_then(|_| std::io::copy(&mut temp_file, &mut output))
1842        .and_then(|_| output.flush());
1843
1844    if copy_result.is_err() {
1845        let _ = destination.parent.remove_file_or_symlink(&destination.leaf);
1846        let _ = destination.parent.remove_file_or_symlink(temp_leaf);
1847        return Err(FormatError::FilesystemExtractionFailed(
1848            "failed to write regular file",
1849        ));
1850    }
1851
1852    let _ = destination.parent.remove_file_or_symlink(temp_leaf);
1853    Ok(output)
1854}
1855
1856fn remove_existing_leaf_if_needed(destination: &PreparedDestination) -> Result<(), FormatError> {
1857    match destination.parent.symlink_metadata(&destination.leaf) {
1858        Ok(metadata) => {
1859            if metadata.file_type().is_dir() {
1860                return Err(FormatError::UnsafeOverwrite);
1861            }
1862            destination
1863                .parent
1864                .remove_file_or_symlink(&destination.leaf)
1865                .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))
1866        }
1867        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1868        Err(_) => Err(FormatError::FilesystemExtractionFailed(
1869            "failed to inspect destination",
1870        )),
1871    }
1872}
1873
1874fn create_directory(destination: &PreparedDestination) -> Result<(), FormatError> {
1875    match destination.parent.create_dir(&destination.leaf) {
1876        Ok(()) => Ok(()),
1877        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1878            let metadata = destination
1879                .parent
1880                .symlink_metadata(&destination.leaf)
1881                .map_err(|_| FormatError::UnsafeOverwrite)?;
1882            let file_type = metadata.file_type();
1883            if file_type.is_symlink() {
1884                Err(FormatError::UnsafeArchivePath)
1885            } else if file_type.is_dir() {
1886                Ok(())
1887            } else {
1888                Err(FormatError::UnsafeOverwrite)
1889            }
1890        }
1891        Err(_) => Err(FormatError::FilesystemExtractionFailed(
1892            "failed to create directory",
1893        )),
1894    }
1895}
1896
1897fn create_hardlink(
1898    destination: &PreparedDestination,
1899    target: &PreparedDestination,
1900    options: SafeExtractionOptions,
1901) -> Result<(), FormatError> {
1902    if options.overwrite_existing {
1903        remove_existing_leaf_if_needed(destination)?;
1904    }
1905    match target
1906        .parent
1907        .hard_link(&target.leaf, &destination.parent, &destination.leaf)
1908    {
1909        Ok(()) => {
1910            let metadata = destination
1911                .parent
1912                .symlink_metadata(&destination.leaf)
1913                .map_err(|_| {
1914                    FormatError::FilesystemExtractionFailed("failed to inspect hardlink")
1915                })?;
1916            if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
1917                let _ = destination.parent.remove_file_or_symlink(&destination.leaf);
1918                return Err(FormatError::UnsafeArchivePath);
1919            }
1920            Ok(())
1921        }
1922        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1923            Err(FormatError::UnsafeOverwrite)
1924        }
1925        Err(_) => Err(FormatError::FilesystemExtractionFailed(
1926            "failed to create hardlink",
1927        )),
1928    }
1929}
1930
1931fn create_symlink(
1932    destination: &PreparedDestination,
1933    target: &[u8],
1934    options: SafeExtractionOptions,
1935) -> Result<(), FormatError> {
1936    if options.overwrite_existing {
1937        remove_existing_leaf_if_needed(destination)?;
1938    }
1939    let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
1940    match destination.parent.symlink_file(target, &destination.leaf) {
1941        Ok(()) => Ok(()),
1942        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1943            Err(FormatError::UnsafeOverwrite)
1944        }
1945        Err(_) => Err(FormatError::FilesystemExtractionFailed(
1946            "failed to create symlink",
1947        )),
1948    }
1949}
1950
1951fn path_components(path: &[u8]) -> Result<Vec<String>, FormatError> {
1952    validate_file_path_bytes(path, u32::MAX)?;
1953    let path = std::str::from_utf8(path).map_err(|_| FormatError::UnsafeArchivePath)?;
1954    Ok(path.split('/').map(str::to_owned).collect())
1955}
1956
1957fn ustar_path(header: &[u8]) -> Vec<u8> {
1958    let name = nul_trimmed(&header[0..100]);
1959    let prefix = nul_trimmed(&header[345..500]);
1960    if prefix.is_empty() {
1961        name.to_vec()
1962    } else {
1963        let mut out = Vec::with_capacity(prefix.len() + 1 + name.len());
1964        out.extend_from_slice(prefix);
1965        out.push(b'/');
1966        out.extend_from_slice(name);
1967        out
1968    }
1969}
1970
1971fn trimmed_metadata_payload(payload: &[u8]) -> Vec<u8> {
1972    let mut end = payload.len();
1973    while end > 0 && payload[end - 1] == 0 {
1974        end -= 1;
1975    }
1976    payload[..end].to_vec()
1977}
1978
1979fn verify_tar_checksum(header: &[u8]) -> Result<(), FormatError> {
1980    let stored = parse_tar_octal(&header[148..156])?;
1981    let mut sum = 0u64;
1982    for (idx, byte) in header.iter().enumerate() {
1983        if (148..156).contains(&idx) {
1984            sum += b' ' as u64;
1985        } else {
1986            sum += *byte as u64;
1987        }
1988    }
1989    if stored != sum {
1990        return Err(FormatError::InvalidArchive("tar header checksum mismatch"));
1991    }
1992    Ok(())
1993}
1994
1995fn parse_tar_octal(field: &[u8]) -> Result<u64, FormatError> {
1996    let mut value = 0u64;
1997    let mut saw_digit = false;
1998    for byte in field {
1999        match *byte {
2000            0 | b' ' if saw_digit => break,
2001            0 | b' ' => {}
2002            b'0'..=b'7' => {
2003                saw_digit = true;
2004                value = value
2005                    .checked_mul(8)
2006                    .and_then(|acc| acc.checked_add((*byte - b'0') as u64))
2007                    .ok_or(FormatError::InvalidArchive("tar octal field overflow"))?;
2008            }
2009            _ => return Err(FormatError::InvalidArchive("malformed tar octal field")),
2010        }
2011    }
2012    Ok(value)
2013}
2014
2015fn parse_decimal(field: &[u8]) -> Result<usize, FormatError> {
2016    let mut value = 0usize;
2017    if field.is_empty() {
2018        return Err(FormatError::InvalidArchive("malformed decimal field"));
2019    }
2020    for byte in field {
2021        if !byte.is_ascii_digit() {
2022            return Err(FormatError::InvalidArchive("malformed decimal field"));
2023        }
2024        value = value
2025            .checked_mul(10)
2026            .and_then(|acc| acc.checked_add((byte - b'0') as usize))
2027            .ok_or(FormatError::InvalidArchive("decimal field overflow"))?;
2028    }
2029    Ok(value)
2030}
2031
2032fn nul_trimmed(bytes: &[u8]) -> &[u8] {
2033    let end = bytes
2034        .iter()
2035        .position(|byte| *byte == 0)
2036        .unwrap_or(bytes.len());
2037    &bytes[..end]
2038}
2039
2040fn padding_to_512(len: usize) -> usize {
2041    let remainder = len % TAR_BLOCK_LEN;
2042    if remainder == 0 {
2043        0
2044    } else {
2045        TAR_BLOCK_LEN - remainder
2046    }
2047}
2048
2049fn padding_to_512_u64(len: u64) -> u64 {
2050    let remainder = len % TAR_BLOCK_LEN as u64;
2051    if remainder == 0 {
2052        0
2053    } else {
2054        TAR_BLOCK_LEN as u64 - remainder
2055    }
2056}
2057
2058fn slice(bytes: &[u8], offset: usize, len: usize) -> Result<&[u8], FormatError> {
2059    let end = checked_add(offset, len)?;
2060    bytes.get(offset..end).ok_or(FormatError::InvalidLength {
2061        structure: "tar member",
2062        expected: end,
2063        actual: bytes.len(),
2064    })
2065}
2066
2067fn checked_add(lhs: usize, rhs: usize) -> Result<usize, FormatError> {
2068    lhs.checked_add(rhs).ok_or(FormatError::InvalidArchive(
2069        "tar member arithmetic overflow",
2070    ))
2071}
2072
2073fn to_usize(value: u64) -> Result<usize, FormatError> {
2074    usize::try_from(value).map_err(|_| FormatError::InvalidArchive("tar member size overflow"))
2075}
2076
2077#[cfg(test)]
2078mod tests {
2079    use super::*;
2080    use tempfile::tempdir;
2081
2082    fn header(path: &[u8], kind: u8, size: usize, link: &[u8]) -> [u8; TAR_BLOCK_LEN] {
2083        let mut header = [0u8; TAR_BLOCK_LEN];
2084        header[..path.len()].copy_from_slice(path);
2085        write_octal(&mut header[100..108], 0o644);
2086        write_octal(&mut header[108..116], 0);
2087        write_octal(&mut header[116..124], 0);
2088        write_octal(&mut header[124..136], size as u64);
2089        write_octal(&mut header[136..148], 0);
2090        header[148..156].fill(b' ');
2091        header[156] = kind;
2092        header[157..157 + link.len()].copy_from_slice(link);
2093        header[257..263].copy_from_slice(b"ustar\0");
2094        header[263..265].copy_from_slice(b"00");
2095        let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
2096        write_checksum(&mut header[148..156], checksum);
2097        header
2098    }
2099
2100    fn member(path: &[u8], kind: u8, data: &[u8], link: &[u8]) -> Vec<u8> {
2101        member_with_declared_size(path, kind, data.len(), data, link)
2102    }
2103
2104    fn member_with_declared_size(
2105        path: &[u8],
2106        kind: u8,
2107        declared_size: usize,
2108        data: &[u8],
2109        link: &[u8],
2110    ) -> Vec<u8> {
2111        let mut out = Vec::new();
2112        out.extend_from_slice(&header(path, kind, declared_size, link));
2113        out.extend_from_slice(data);
2114        out.resize(out.len() + padding_to_512(data.len()), 0);
2115        out
2116    }
2117
2118    fn member_with_prefix(prefix: &[u8], path: &[u8], kind: u8, data: &[u8]) -> Vec<u8> {
2119        let mut header = header(path, kind, data.len(), b"");
2120        header[345..345 + prefix.len()].copy_from_slice(prefix);
2121        header[148..156].fill(b' ');
2122        let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
2123        write_checksum(&mut header[148..156], checksum);
2124
2125        let mut out = Vec::new();
2126        out.extend_from_slice(&header);
2127        out.extend_from_slice(data);
2128        out.resize(out.len() + padding_to_512(data.len()), 0);
2129        out
2130    }
2131
2132    fn pax_record(key: &str, value: &[u8]) -> Vec<u8> {
2133        let mut len = key.len() + value.len() + 4;
2134        loop {
2135            let candidate = len.to_string().len() + 1 + key.len() + 1 + value.len() + 1;
2136            if candidate == len {
2137                break;
2138            }
2139            len = candidate;
2140        }
2141        let mut out = Vec::new();
2142        out.extend_from_slice(len.to_string().as_bytes());
2143        out.push(b' ');
2144        out.extend_from_slice(key.as_bytes());
2145        out.push(b'=');
2146        out.extend_from_slice(value);
2147        out.push(b'\n');
2148        out
2149    }
2150
2151    fn write_octal(field: &mut [u8], value: u64) {
2152        let digits = format!("{value:o}");
2153        field.fill(0);
2154        let start = field.len() - 1 - digits.len();
2155        field[..start].fill(b'0');
2156        field[start..start + digits.len()].copy_from_slice(digits.as_bytes());
2157    }
2158
2159    fn write_checksum(field: &mut [u8], value: u64) {
2160        let digits = format!("{value:06o}");
2161        field[0..6].copy_from_slice(digits.as_bytes());
2162        field[6] = 0;
2163        field[7] = b' ';
2164    }
2165
2166    #[test]
2167    fn parses_ustar_regular_member() {
2168        let bytes = member(b"dir/file.txt", b'0', b"hello", b"");
2169        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2170
2171        assert_eq!(parsed.kind, TarEntryKind::Regular);
2172        assert_eq!(parsed.path, b"dir/file.txt");
2173        assert_eq!(parsed.data, b"hello");
2174        assert_eq!(parsed.logical_size, 5);
2175    }
2176
2177    #[test]
2178    fn canonicalizes_one_directory_trailing_slash_only_for_directories() {
2179        let dir = member(b"dir/", b'5', b"", b"");
2180        assert_eq!(parse_tar_member_group(&dir, 4096).unwrap().path, b"dir");
2181
2182        let file = member(b"dir/", b'0', b"", b"");
2183        assert_eq!(
2184            parse_tar_member_group(&file, 4096).unwrap_err(),
2185            FormatError::UnsafeArchivePath
2186        );
2187    }
2188
2189    #[test]
2190    fn rejects_global_pax_headers() {
2191        let bytes = member(b"pax", b'g', b"11 path=x\n", b"");
2192        assert_eq!(
2193            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2194            FormatError::InvalidArchive("global PAX headers are not allowed")
2195        );
2196    }
2197
2198    #[test]
2199    fn rejects_global_pax_before_main_entry() {
2200        let global_pax = pax_record("path", b"poisoned.txt");
2201        let mut bytes = member(b"GlobalHead/path", b'g', &global_pax, b"");
2202        bytes.extend_from_slice(&member(b"safe.txt", b'0', b"abc", b""));
2203
2204        assert_eq!(
2205            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2206            FormatError::InvalidArchive("global PAX headers are not allowed")
2207        );
2208    }
2209
2210    #[test]
2211    fn rejects_global_gnu_headers() {
2212        for typeflag in [b'V', b'M', b'N'] {
2213            let bytes = member(b"global", typeflag, b"archive-label", b"");
2214
2215            assert_eq!(
2216                parse_tar_member_group(&bytes, 4096).unwrap_err(),
2217                FormatError::InvalidArchive("global GNU headers are not allowed"),
2218                "typeflag {typeflag:?}"
2219            );
2220        }
2221    }
2222
2223    #[test]
2224    fn rejects_unsupported_gnu_sparse_entry_type() {
2225        let bytes = member(b"sparse.bin", b'S', b"", b"");
2226
2227        assert_eq!(
2228            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2229            FormatError::ReaderUnsupported("unsupported GNU sparse tar entry")
2230        );
2231    }
2232
2233    #[test]
2234    fn applies_local_pax_path_and_size() {
2235        let pax = pax_record("path", b"long/name.txt");
2236        let mut bytes = member(b"PaxHeaders/name", b'x', &pax, b"");
2237        bytes.extend_from_slice(&member(b"short", b'0', b"abc", b""));
2238
2239        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2240        assert_eq!(parsed.path, b"long/name.txt");
2241        assert_eq!(parsed.data, b"abc");
2242    }
2243
2244    #[test]
2245    fn applies_local_gnu_long_name_and_link_to_following_entry() {
2246        let mut named = member(b"././@LongLink", b'L', b"long/path.txt\0", b"");
2247        named.extend_from_slice(&member(b"short", b'0', b"abc", b""));
2248        let parsed = parse_tar_member_group(&named, 4096).unwrap();
2249        assert_eq!(parsed.path, b"long/path.txt");
2250        assert_eq!(parsed.data, b"abc");
2251
2252        let mut linked = member(b"././@LongLink", b'K', b"target/file.txt\0", b"");
2253        linked.extend_from_slice(&member(b"short-link", b'2', b"", b"fallback"));
2254        let parsed = parse_tar_member_group(&linked, 4096).unwrap();
2255        assert_eq!(parsed.path, b"short-link");
2256        assert_eq!(
2257            parsed.link_target.as_deref(),
2258            Some(b"target/file.txt".as_slice())
2259        );
2260    }
2261
2262    #[test]
2263    fn supported_tar_metadata_profile_matrix_matches_buffered_and_streaming_parsers() {
2264        struct Case {
2265            name: &'static str,
2266            bytes: Vec<u8>,
2267            expected_path: &'static [u8],
2268            expected_kind: TarEntryKind,
2269            expected_data: &'static [u8],
2270            expected_link_target: Option<&'static [u8]>,
2271            expected_logical_size: u64,
2272        }
2273
2274        let mut pax_path_and_size = member(
2275            b"PaxHeaders/pax-file",
2276            b'x',
2277            &[
2278                pax_record("path", b"deep/pax-name.txt"),
2279                pax_record("size", b"5"),
2280            ]
2281            .concat(),
2282            b"",
2283        );
2284        pax_path_and_size.extend_from_slice(&member_with_declared_size(
2285            b"fallback",
2286            b'0',
2287            0,
2288            b"hello",
2289            b"",
2290        ));
2291
2292        let mut pax_linkpath = member(
2293            b"PaxHeaders/pax-link",
2294            b'x',
2295            &pax_record("linkpath", b"target/file.txt"),
2296            b"",
2297        );
2298        pax_linkpath.extend_from_slice(&member(b"links/link", b'2', b"", b"fallback-target"));
2299
2300        let mut gnu_long_name = member(b"././@LongLink", b'L', b"long/path/name.txt\0", b"");
2301        gnu_long_name.extend_from_slice(&member(b"short", b'0', b"abc", b""));
2302
2303        let mut gnu_long_link = member(b"././@LongLink", b'K', b"target/hard.txt\0", b"");
2304        gnu_long_link.extend_from_slice(&member(b"links/hard", b'1', b"", b"fallback"));
2305
2306        let cases = vec![
2307            Case {
2308                name: "regular ustar member",
2309                bytes: member(b"dir/file.txt", b'0', b"hello", b""),
2310                expected_path: b"dir/file.txt",
2311                expected_kind: TarEntryKind::Regular,
2312                expected_data: b"hello",
2313                expected_link_target: None,
2314                expected_logical_size: 5,
2315            },
2316            Case {
2317                name: "ustar prefix plus name",
2318                bytes: member_with_prefix(b"dir/prefix", b"file.txt", b'0', b"abc"),
2319                expected_path: b"dir/prefix/file.txt",
2320                expected_kind: TarEntryKind::Regular,
2321                expected_data: b"abc",
2322                expected_link_target: None,
2323                expected_logical_size: 3,
2324            },
2325            Case {
2326                name: "directory trailing slash",
2327                bytes: member(b"dir/", b'5', b"", b""),
2328                expected_path: b"dir",
2329                expected_kind: TarEntryKind::Directory,
2330                expected_data: b"",
2331                expected_link_target: None,
2332                expected_logical_size: 0,
2333            },
2334            Case {
2335                name: "local pax path and size",
2336                bytes: pax_path_and_size,
2337                expected_path: b"deep/pax-name.txt",
2338                expected_kind: TarEntryKind::Regular,
2339                expected_data: b"hello",
2340                expected_link_target: None,
2341                expected_logical_size: 5,
2342            },
2343            Case {
2344                name: "local pax linkpath",
2345                bytes: pax_linkpath,
2346                expected_path: b"links/link",
2347                expected_kind: TarEntryKind::Symlink,
2348                expected_data: b"",
2349                expected_link_target: Some(b"target/file.txt"),
2350                expected_logical_size: 0,
2351            },
2352            Case {
2353                name: "gnu long name",
2354                bytes: gnu_long_name,
2355                expected_path: b"long/path/name.txt",
2356                expected_kind: TarEntryKind::Regular,
2357                expected_data: b"abc",
2358                expected_link_target: None,
2359                expected_logical_size: 3,
2360            },
2361            Case {
2362                name: "gnu long link",
2363                bytes: gnu_long_link,
2364                expected_path: b"links/hard",
2365                expected_kind: TarEntryKind::Hardlink,
2366                expected_data: b"",
2367                expected_link_target: Some(b"target/hard.txt"),
2368                expected_logical_size: 0,
2369            },
2370        ];
2371
2372        for case in cases {
2373            let parsed = parse_tar_member_group(&case.bytes, 4096).unwrap_or_else(|err| {
2374                panic!("{} should parse in buffered tar parser: {err:?}", case.name)
2375            });
2376            assert_eq!(parsed.path, case.expected_path, "{}", case.name);
2377            assert_eq!(parsed.kind, case.expected_kind, "{}", case.name);
2378            assert_eq!(parsed.data, case.expected_data, "{}", case.name);
2379            assert_eq!(
2380                parsed.link_target.as_deref(),
2381                case.expected_link_target,
2382                "{}",
2383                case.name
2384            );
2385            assert_eq!(
2386                parsed.logical_size, case.expected_logical_size,
2387                "{}",
2388                case.name
2389            );
2390
2391            let mut streaming = TarStreamSummaryValidator::with_observer(
2392                4096,
2393                u64::MAX,
2394                4096,
2395                16,
2396                NoopTarStreamObserver,
2397            );
2398            streaming.observe(&case.bytes).unwrap_or_else(|err| {
2399                panic!(
2400                    "{} should parse in streaming tar parser: {err:?}",
2401                    case.name
2402                )
2403            });
2404            let summary = streaming.finish().unwrap_or_else(|err| {
2405                panic!(
2406                    "{} should finish in streaming tar parser: {err:?}",
2407                    case.name
2408                )
2409            });
2410            assert_eq!(summary.members.len(), 1, "{}", case.name);
2411            let member = &summary.members[0];
2412            assert_eq!(member.path, case.expected_path, "{}", case.name);
2413            assert_eq!(member.kind, case.expected_kind, "{}", case.name);
2414            assert_eq!(
2415                member.link_target.as_deref(),
2416                case.expected_link_target,
2417                "{}",
2418                case.name
2419            );
2420            assert_eq!(
2421                member.logical_size, case.expected_logical_size,
2422                "{}",
2423                case.name
2424            );
2425        }
2426    }
2427
2428    #[test]
2429    fn tar_metadata_rejects_unsafe_or_inconsistent_overrides_matrix() {
2430        let mut pax_absolute_path = member(
2431            b"PaxHeaders/file",
2432            b'x',
2433            &pax_record("path", b"/absolute"),
2434            b"",
2435        );
2436        pax_absolute_path.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
2437
2438        let mut pax_parent_path = member(
2439            b"PaxHeaders/file",
2440            b'x',
2441            &pax_record("path", b"../escape"),
2442            b"",
2443        );
2444        pax_parent_path.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
2445
2446        let mut pax_absolute_link = member(
2447            b"PaxHeaders/link",
2448            b'x',
2449            &pax_record("linkpath", b"/target"),
2450            b"",
2451        );
2452        pax_absolute_link.extend_from_slice(&member(b"links/link", b'2', b"", b"safe"));
2453
2454        let mut gnu_unsafe_name = member(b"././@LongLink", b'L', b"bad:name.txt\0", b"");
2455        gnu_unsafe_name.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
2456
2457        let mut gnu_parent_hardlink = member(b"././@LongLink", b'K', b"../target.txt\0", b"");
2458        gnu_parent_hardlink.extend_from_slice(&member(b"links/hard", b'1', b"", b"safe"));
2459
2460        let mut pax_size_on_directory =
2461            member(b"PaxHeaders/dir", b'x', &pax_record("size", b"1"), b"");
2462        pax_size_on_directory
2463            .extend_from_slice(&member_with_declared_size(b"dir", b'5', 0, b"x", b""));
2464
2465        for (name, bytes, expected) in [
2466            (
2467                "pax absolute path",
2468                pax_absolute_path,
2469                FormatError::UnsafeArchivePath,
2470            ),
2471            (
2472                "pax parent path",
2473                pax_parent_path,
2474                FormatError::UnsafeArchivePath,
2475            ),
2476            (
2477                "pax absolute symlink target",
2478                pax_absolute_link,
2479                FormatError::UnsafeArchivePath,
2480            ),
2481            (
2482                "gnu unsafe long name",
2483                gnu_unsafe_name,
2484                FormatError::UnsafeArchivePath,
2485            ),
2486            (
2487                "gnu hardlink parent target",
2488                gnu_parent_hardlink,
2489                FormatError::UnsafeArchivePath,
2490            ),
2491            (
2492                "pax size on directory",
2493                pax_size_on_directory,
2494                FormatError::InvalidArchive("non-regular tar entry has non-zero payload size"),
2495            ),
2496        ] {
2497            assert_eq!(
2498                parse_tar_member_group(&bytes, 4096).unwrap_err(),
2499                expected,
2500                "{name}"
2501            );
2502
2503            let mut streaming = TarStreamSummaryValidator::with_observer(
2504                4096,
2505                u64::MAX,
2506                4096,
2507                16,
2508                NoopTarStreamObserver,
2509            );
2510            assert_eq!(streaming.observe(&bytes).unwrap_err(), expected, "{name}");
2511        }
2512    }
2513
2514    #[test]
2515    fn pax_size_exceeding_available_group_is_rejected_by_buffered_and_streaming_parsers() {
2516        let mut bytes = member(b"PaxHeaders/file", b'x', &pax_record("size", b"4096"), b"");
2517        bytes.extend_from_slice(&member_with_declared_size(b"file", b'0', 0, b"short", b""));
2518
2519        assert_eq!(
2520            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2521            FormatError::InvalidLength {
2522                structure: "tar member",
2523                expected: 5632,
2524                actual: 2048,
2525            }
2526        );
2527
2528        let mut streaming = TarStreamSummaryValidator::with_observer(
2529            4096,
2530            u64::MAX,
2531            4096,
2532            16,
2533            NoopTarStreamObserver,
2534        );
2535        streaming.observe(&bytes).unwrap();
2536        assert_eq!(
2537            streaming.finish().unwrap_err(),
2538            FormatError::InvalidArchive("tar stream ended inside member group")
2539        );
2540    }
2541
2542    #[test]
2543    fn malformed_pax_record_matrix_rejects_before_metadata_is_trusted() {
2544        let cases: Vec<(&str, Vec<u8>)> = vec![
2545            ("missing length", b"path=file\n".to_vec()),
2546            ("missing space", b"12path=file\n".to_vec()),
2547            ("record too short", b"3 a\n".to_vec()),
2548            ("missing newline", b"11 path=file".to_vec()),
2549            ("missing equals", b"10 pathfile\n".to_vec()),
2550            ("non utf8 key", vec![7, b' ', 0xff, b'=', b'x', b'\n']),
2551            ("bad size value", pax_record("size", b"12x")),
2552        ];
2553
2554        for (name, payload) in cases {
2555            let mut bytes = member(b"PaxHeaders/file", b'x', &payload, b"");
2556            bytes.extend_from_slice(&member(b"file", b'0', b"abc", b""));
2557
2558            assert!(
2559                matches!(
2560                    parse_tar_member_group(&bytes, 4096).unwrap_err(),
2561                    FormatError::InvalidArchive(_)
2562                ),
2563                "{name}"
2564            );
2565
2566            let mut streaming = TarStreamSummaryValidator::with_observer(
2567                4096,
2568                u64::MAX,
2569                4096,
2570                16,
2571                NoopTarStreamObserver,
2572            );
2573            assert!(
2574                matches!(
2575                    streaming.observe(&bytes).unwrap_err(),
2576                    FormatError::InvalidArchive(_)
2577                ),
2578                "{name}"
2579            );
2580        }
2581    }
2582
2583    #[test]
2584    fn reports_degraded_diagnostics_for_xattr_and_acl_pax_profiles() {
2585        let mut pax = Vec::new();
2586        pax.extend_from_slice(&pax_record("SCHILY.xattr.user.comment", b"hello"));
2587        pax.extend_from_slice(&pax_record("LIBARCHIVE.xattr.user.comment", b"hello"));
2588        pax.extend_from_slice(&pax_record("SCHILY.acl.access", b"user::rw-"));
2589        pax.extend_from_slice(&pax_record("LIBARCHIVE.acl.access", b"user::rw-"));
2590        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2591        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2592
2593        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2594
2595        assert_eq!(parsed.path, b"file.txt");
2596        assert_eq!(parsed.data, b"abc");
2597        assert_eq!(
2598            parsed
2599                .diagnostics
2600                .iter()
2601                .filter(|diagnostic| **diagnostic
2602                    == MetadataDiagnostic {
2603                        profile: "pax-xattrs-acls",
2604                        message: "unsupported xattr/ACL PAX metadata was ignored",
2605                    })
2606                .count(),
2607            4
2608        );
2609    }
2610
2611    #[test]
2612    fn reports_degraded_diagnostics_for_pax_timestamp_precision() {
2613        let mut pax = Vec::new();
2614        pax.extend_from_slice(&pax_record("atime", b"1.123456789"));
2615        pax.extend_from_slice(&pax_record("ctime", b"2.123456789"));
2616        pax.extend_from_slice(&pax_record("mtime", b"3.123456789"));
2617        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2618        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2619
2620        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2621
2622        assert_eq!(
2623            parsed
2624                .diagnostics
2625                .iter()
2626                .filter(|diagnostic| **diagnostic
2627                    == MetadataDiagnostic {
2628                        profile: "pax-posix-2001",
2629                        message: "unsupported PAX timestamp metadata was ignored",
2630                    })
2631                .count(),
2632            3
2633        );
2634    }
2635
2636    #[test]
2637    fn reports_degraded_diagnostics_for_sparse_and_unknown_pax_profiles() {
2638        let mut pax = Vec::new();
2639        pax.extend_from_slice(&pax_record("GNU.sparse.realsize", b"1024"));
2640        pax.extend_from_slice(&pax_record("GNU.sparse.map", b"0,1"));
2641        pax.extend_from_slice(&pax_record("comment", b"ignored"));
2642        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2643        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2644
2645        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2646
2647        assert_eq!(
2648            parsed
2649                .diagnostics
2650                .iter()
2651                .filter(|diagnostic| **diagnostic
2652                    == MetadataDiagnostic {
2653                        profile: "gnu-sparse",
2654                        message: "unsupported sparse-file PAX metadata was ignored",
2655                    })
2656                .count(),
2657            2
2658        );
2659        assert_eq!(
2660            parsed
2661                .diagnostics
2662                .iter()
2663                .filter(|diagnostic| **diagnostic
2664                    == MetadataDiagnostic {
2665                        profile: "pax-posix-2001",
2666                        message: "unsupported PAX key was ignored",
2667                    })
2668                .count(),
2669            1
2670        );
2671    }
2672
2673    #[test]
2674    fn reports_degraded_diagnostics_for_unsupported_local_pax_profiles() {
2675        let mut pax = Vec::new();
2676        pax.extend_from_slice(&pax_record("SCHILY.xattr.user.comment", b"hello"));
2677        pax.extend_from_slice(&pax_record("GNU.sparse.realsize", b"1024"));
2678        pax.extend_from_slice(&pax_record("mtime", b"1.123456789"));
2679        pax.extend_from_slice(&pax_record("comment", b"ignored"));
2680        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2681        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2682
2683        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2684
2685        assert_eq!(parsed.path, b"file.txt");
2686        assert_eq!(parsed.data, b"abc");
2687        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2688            profile: "pax-xattrs-acls",
2689            message: "unsupported xattr/ACL PAX metadata was ignored",
2690        }));
2691        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2692            profile: "gnu-sparse",
2693            message: "unsupported sparse-file PAX metadata was ignored",
2694        }));
2695        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2696            profile: "pax-posix-2001",
2697            message: "unsupported PAX timestamp metadata was ignored",
2698        }));
2699        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2700            profile: "pax-posix-2001",
2701            message: "unsupported PAX key was ignored",
2702        }));
2703    }
2704
2705    #[test]
2706    fn rejects_platform_escape_paths() {
2707        for path in [
2708            b"/abs".as_slice(),
2709            b"../up".as_slice(),
2710            b"a//b".as_slice(),
2711            b"a\\b".as_slice(),
2712            b"a:b".as_slice(),
2713            b"CON".as_slice(),
2714        ] {
2715            let bytes = member(path, b'0', b"", b"");
2716            assert_eq!(
2717                parse_tar_member_group(&bytes, 4096).unwrap_err(),
2718                FormatError::UnsafeArchivePath
2719            );
2720        }
2721    }
2722
2723    #[cfg(unix)]
2724    #[test]
2725    fn safe_restore_rejects_symlink_parent() {
2726        let tmp = tempdir().unwrap();
2727        let outside = tempdir().unwrap();
2728        std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap();
2729
2730        let member = OwnedTarMember {
2731            path: b"link/file.txt".to_vec(),
2732            kind: TarEntryKind::Regular,
2733            data: b"blocked".to_vec(),
2734            link_target: None,
2735            mode: 0o644,
2736            mtime: 0,
2737            logical_size: 7,
2738            diagnostics: Vec::new(),
2739        };
2740
2741        assert_eq!(
2742            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap_err(),
2743            FormatError::UnsafeArchivePath
2744        );
2745    }
2746
2747    #[cfg(unix)]
2748    #[test]
2749    fn prepared_regular_file_uses_open_parent_after_parent_path_swap() {
2750        let tmp = tempdir().unwrap();
2751        let outside = tempdir().unwrap();
2752        let original_parent = tmp.path().join("a");
2753        let held_parent = tmp.path().join("held");
2754        fs::create_dir(&original_parent).unwrap();
2755
2756        let destination = prepare_destination(
2757            tmp.path(),
2758            b"a/file.txt",
2759            TarEntryKind::Regular,
2760            SafeExtractionOptions::default(),
2761        )
2762        .unwrap();
2763
2764        fs::rename(&original_parent, &held_parent).unwrap();
2765        std::os::unix::fs::symlink(outside.path(), &original_parent).unwrap();
2766
2767        let (temp_leaf, mut file) = create_temp_regular_file(&destination).unwrap();
2768        file.write_all(b"inside").unwrap();
2769        publish_regular_file(
2770            &destination,
2771            &temp_leaf,
2772            file,
2773            SafeExtractionOptions::default(),
2774        )
2775        .unwrap();
2776
2777        assert_eq!(fs::read(held_parent.join("file.txt")).unwrap(), b"inside");
2778        assert!(!outside.path().join("file.txt").exists());
2779    }
2780
2781    #[cfg(unix)]
2782    #[test]
2783    fn create_directory_rechecks_leaf_without_following_symlink() {
2784        let tmp = tempdir().unwrap();
2785        let outside = tempdir().unwrap();
2786        let destination = prepare_destination(
2787            tmp.path(),
2788            b"dir",
2789            TarEntryKind::Directory,
2790            SafeExtractionOptions::default(),
2791        )
2792        .unwrap();
2793
2794        std::os::unix::fs::symlink(outside.path(), tmp.path().join("dir")).unwrap();
2795
2796        assert_eq!(
2797            create_directory(&destination).unwrap_err(),
2798            FormatError::UnsafeArchivePath
2799        );
2800        assert!(outside.path().read_dir().unwrap().next().is_none());
2801    }
2802
2803    #[test]
2804    fn safe_restore_requires_hardlink_target_to_be_existing_regular_file() {
2805        let tmp = tempdir().unwrap();
2806        fs::write(tmp.path().join("target.txt"), b"target").unwrap();
2807        let member = OwnedTarMember {
2808            path: b"linked.txt".to_vec(),
2809            kind: TarEntryKind::Hardlink,
2810            data: Vec::new(),
2811            link_target: Some(b"target.txt".to_vec()),
2812            mode: 0o644,
2813            mtime: 0,
2814            logical_size: 0,
2815            diagnostics: Vec::new(),
2816        };
2817
2818        restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2819        assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
2820    }
2821
2822    #[cfg(unix)]
2823    #[test]
2824    fn restore_applies_regular_file_mode_metadata() {
2825        let tmp = tempdir().unwrap();
2826        let member = OwnedTarMember {
2827            path: b"script.sh".to_vec(),
2828            kind: TarEntryKind::Regular,
2829            data: b"#!/bin/sh\n".to_vec(),
2830            link_target: None,
2831            mode: 0o755,
2832            mtime: 0,
2833            logical_size: 10,
2834            diagnostics: Vec::new(),
2835        };
2836
2837        let diagnostics =
2838            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2839
2840        assert!(diagnostics.is_empty());
2841        let mode = fs::metadata(tmp.path().join("script.sh"))
2842            .unwrap()
2843            .permissions()
2844            .mode()
2845            & 0o777;
2846        assert_eq!(mode, 0o755);
2847    }
2848
2849    #[test]
2850    fn restore_applies_regular_file_mtime_metadata() {
2851        let tmp = tempdir().unwrap();
2852        let member = OwnedTarMember {
2853            path: b"dated.txt".to_vec(),
2854            kind: TarEntryKind::Regular,
2855            data: b"dated".to_vec(),
2856            link_target: None,
2857            mode: 0o666,
2858            mtime: 1_700_000_000,
2859            logical_size: 5,
2860            diagnostics: Vec::new(),
2861        };
2862
2863        let diagnostics =
2864            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2865
2866        assert!(diagnostics.is_empty());
2867        let modified = fs::metadata(tmp.path().join("dated.txt"))
2868            .unwrap()
2869            .modified()
2870            .unwrap()
2871            .duration_since(SystemTime::UNIX_EPOCH)
2872            .unwrap()
2873            .as_secs();
2874        assert_eq!(modified, 1_700_000_000);
2875    }
2876
2877    #[test]
2878    fn restore_revalidates_symlink_targets_from_owned_members() {
2879        let tmp = tempdir().unwrap();
2880        let member = OwnedTarMember {
2881            path: b"link".to_vec(),
2882            kind: TarEntryKind::Symlink,
2883            data: Vec::new(),
2884            link_target: Some(b"/outside".to_vec()),
2885            mode: 0o644,
2886            mtime: 0,
2887            logical_size: 0,
2888            diagnostics: Vec::new(),
2889        };
2890
2891        assert_eq!(
2892            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap_err(),
2893            FormatError::UnsafeArchivePath
2894        );
2895        assert!(!tmp.path().join("link").exists());
2896    }
2897
2898    #[test]
2899    fn safe_restore_rejects_directory_over_existing_file_even_with_overwrite() {
2900        let tmp = tempdir().unwrap();
2901        let conflict = tmp.path().join("conflict");
2902        fs::write(&conflict, b"not a directory").unwrap();
2903        let member = OwnedTarMember {
2904            path: b"conflict".to_vec(),
2905            kind: TarEntryKind::Directory,
2906            data: Vec::new(),
2907            link_target: None,
2908            mode: 0o644,
2909            mtime: 0,
2910            logical_size: 0,
2911            diagnostics: Vec::new(),
2912        };
2913
2914        assert_eq!(
2915            restore_tar_member(
2916                tmp.path(),
2917                &member,
2918                SafeExtractionOptions {
2919                    overwrite_existing: true
2920                }
2921            )
2922            .unwrap_err(),
2923            FormatError::UnsafeOverwrite
2924        );
2925        assert!(conflict.is_file());
2926    }
2927
2928    #[test]
2929    fn hardlink_target_checks_use_component_position_not_value() {
2930        let tmp = tempdir().unwrap();
2931        fs::create_dir(tmp.path().join("a")).unwrap();
2932        fs::write(tmp.path().join("a").join("a"), b"target").unwrap();
2933        let member = OwnedTarMember {
2934            path: b"linked.txt".to_vec(),
2935            kind: TarEntryKind::Hardlink,
2936            data: Vec::new(),
2937            link_target: Some(b"a/a".to_vec()),
2938            mode: 0o644,
2939            mtime: 0,
2940            logical_size: 0,
2941            diagnostics: Vec::new(),
2942        };
2943
2944        restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2945        assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
2946    }
2947
2948    #[test]
2949    fn hardlink_targets_obey_max_path_length() {
2950        let bytes = member(b"link", b'1', b"", b"long/name");
2951
2952        assert_eq!(
2953            parse_tar_member_group(&bytes, 4).unwrap_err(),
2954            FormatError::UnsafeArchivePath
2955        );
2956    }
2957}