Skip to main content

tzap_core/
tar_model.rs

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