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                fs::create_dir(&current).map_err(|_| {
1691                    FormatError::FilesystemExtractionFailed("failed to create parent directory")
1692                })?;
1693                let metadata = fs::symlink_metadata(&current).map_err(|_| {
1694                    FormatError::FilesystemExtractionFailed(
1695                        "failed to inspect created parent directory",
1696                    )
1697                })?;
1698                if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
1699                    return Err(FormatError::UnsafeArchivePath);
1700                }
1701            }
1702            Err(_) => {
1703                return Err(FormatError::FilesystemExtractionFailed(
1704                    "failed to inspect parent directory",
1705                ));
1706            }
1707        }
1708    }
1709
1710    current.push(components.last().ok_or(FormatError::UnsafeArchivePath)?);
1711    match fs::symlink_metadata(&current) {
1712        Ok(metadata) => {
1713            let file_type = metadata.file_type();
1714            if file_type.is_symlink() {
1715                return Err(FormatError::UnsafeArchivePath);
1716            }
1717            if kind == TarEntryKind::Directory {
1718                if file_type.is_dir() {
1719                    return Ok(current);
1720                }
1721                return Err(FormatError::UnsafeOverwrite);
1722            }
1723            if file_type.is_dir() {
1724                return Err(FormatError::UnsafeOverwrite);
1725            }
1726            if !options.overwrite_existing {
1727                return Err(FormatError::UnsafeOverwrite);
1728            }
1729        }
1730        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1731        Err(_) => {
1732            return Err(FormatError::FilesystemExtractionFailed(
1733                "failed to inspect destination",
1734            ));
1735        }
1736    }
1737    Ok(current)
1738}
1739
1740fn validate_root(root: &Path) -> Result<(), FormatError> {
1741    let metadata = fs::symlink_metadata(root).map_err(|_| {
1742        FormatError::FilesystemExtractionFailed("extraction root must already exist")
1743    })?;
1744    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
1745        return Err(FormatError::UnsafeArchivePath);
1746    }
1747    Ok(())
1748}
1749
1750fn existing_safe_regular_path(root: &Path, archive_path: &[u8]) -> Result<PathBuf, FormatError> {
1751    validate_file_path_bytes(archive_path, u32::MAX)?;
1752    let components = path_components(archive_path)?;
1753    validate_root(root)?;
1754    let mut current = root.to_path_buf();
1755    for (idx, component) in components.iter().enumerate() {
1756        current.push(component);
1757        let metadata =
1758            fs::symlink_metadata(&current).map_err(|_| FormatError::UnsafeArchivePath)?;
1759        if metadata.file_type().is_symlink() {
1760            return Err(FormatError::UnsafeArchivePath);
1761        }
1762        if idx + 1 != components.len() {
1763            if !metadata.file_type().is_dir() {
1764                return Err(FormatError::UnsafeArchivePath);
1765            }
1766        } else if !metadata.file_type().is_file() {
1767            return Err(FormatError::UnsafeArchivePath);
1768        }
1769    }
1770    Ok(current)
1771}
1772
1773fn write_regular_file(
1774    destination: &Path,
1775    data: &[u8],
1776    options: SafeExtractionOptions,
1777) -> Result<fs::File, FormatError> {
1778    if options.overwrite_existing && destination.exists() {
1779        fs::remove_file(destination)
1780            .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
1781    }
1782    let mut file = OpenOptions::new()
1783        .write(true)
1784        .create_new(true)
1785        .open(destination)
1786        .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create regular file"))?;
1787    file.write_all(data)
1788        .map_err(|_| FormatError::FilesystemExtractionFailed("failed to write regular file"))?;
1789    Ok(file)
1790}
1791
1792fn create_temp_regular_file(destination: &Path) -> Result<(PathBuf, fs::File), FormatError> {
1793    let pid = std::process::id();
1794    for attempt in 0..1000u32 {
1795        let mut candidate = destination.as_os_str().to_os_string();
1796        candidate.push(format!(".tzap-tmp-{pid}-{attempt}"));
1797        let path = PathBuf::from(candidate);
1798        match OpenOptions::new().write(true).create_new(true).open(&path) {
1799            Ok(file) => return Ok((path, file)),
1800            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1801            Err(_) => {
1802                return Err(FormatError::FilesystemExtractionFailed(
1803                    "failed to create regular file",
1804                ));
1805            }
1806        }
1807    }
1808    Err(FormatError::FilesystemExtractionFailed(
1809        "failed to create regular file",
1810    ))
1811}
1812
1813fn create_directory(destination: &Path) -> Result<(), FormatError> {
1814    match fs::create_dir(destination) {
1815        Ok(()) => Ok(()),
1816        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1817            let metadata =
1818                fs::symlink_metadata(destination).map_err(|_| FormatError::UnsafeOverwrite)?;
1819            if metadata.file_type().is_dir() {
1820                Ok(())
1821            } else {
1822                Err(FormatError::UnsafeOverwrite)
1823            }
1824        }
1825        Err(_) => Err(FormatError::FilesystemExtractionFailed(
1826            "failed to create directory",
1827        )),
1828    }
1829}
1830
1831fn create_hardlink(
1832    destination: &Path,
1833    target: &Path,
1834    options: SafeExtractionOptions,
1835) -> Result<(), FormatError> {
1836    if options.overwrite_existing && destination.exists() {
1837        fs::remove_file(destination)
1838            .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
1839    }
1840    fs::hard_link(target, destination)
1841        .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create hardlink"))
1842}
1843
1844#[cfg(unix)]
1845fn create_symlink(
1846    destination: &Path,
1847    target: &[u8],
1848    options: SafeExtractionOptions,
1849) -> Result<(), FormatError> {
1850    if options.overwrite_existing && destination.exists() {
1851        fs::remove_file(destination)
1852            .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
1853    }
1854    let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
1855    std::os::unix::fs::symlink(target, destination)
1856        .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create symlink"))
1857}
1858
1859#[cfg(windows)]
1860fn create_symlink(
1861    destination: &Path,
1862    target: &[u8],
1863    options: SafeExtractionOptions,
1864) -> Result<(), FormatError> {
1865    if options.overwrite_existing && destination.exists() {
1866        fs::remove_file(destination)
1867            .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
1868    }
1869    let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
1870    std::os::windows::fs::symlink_file(target, destination)
1871        .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create symlink"))
1872}
1873
1874fn path_components(path: &[u8]) -> Result<Vec<String>, FormatError> {
1875    validate_file_path_bytes(path, u32::MAX)?;
1876    let path = std::str::from_utf8(path).map_err(|_| FormatError::UnsafeArchivePath)?;
1877    Ok(path.split('/').map(str::to_owned).collect())
1878}
1879
1880fn ustar_path(header: &[u8]) -> Vec<u8> {
1881    let name = nul_trimmed(&header[0..100]);
1882    let prefix = nul_trimmed(&header[345..500]);
1883    if prefix.is_empty() {
1884        name.to_vec()
1885    } else {
1886        let mut out = Vec::with_capacity(prefix.len() + 1 + name.len());
1887        out.extend_from_slice(prefix);
1888        out.push(b'/');
1889        out.extend_from_slice(name);
1890        out
1891    }
1892}
1893
1894fn trimmed_metadata_payload(payload: &[u8]) -> Vec<u8> {
1895    let mut end = payload.len();
1896    while end > 0 && payload[end - 1] == 0 {
1897        end -= 1;
1898    }
1899    payload[..end].to_vec()
1900}
1901
1902fn verify_tar_checksum(header: &[u8]) -> Result<(), FormatError> {
1903    let stored = parse_tar_octal(&header[148..156])?;
1904    let mut sum = 0u64;
1905    for (idx, byte) in header.iter().enumerate() {
1906        if (148..156).contains(&idx) {
1907            sum += b' ' as u64;
1908        } else {
1909            sum += *byte as u64;
1910        }
1911    }
1912    if stored != sum {
1913        return Err(FormatError::InvalidArchive("tar header checksum mismatch"));
1914    }
1915    Ok(())
1916}
1917
1918fn parse_tar_octal(field: &[u8]) -> Result<u64, FormatError> {
1919    let mut value = 0u64;
1920    let mut saw_digit = false;
1921    for byte in field {
1922        match *byte {
1923            0 | b' ' if saw_digit => break,
1924            0 | b' ' => {}
1925            b'0'..=b'7' => {
1926                saw_digit = true;
1927                value = value
1928                    .checked_mul(8)
1929                    .and_then(|acc| acc.checked_add((*byte - b'0') as u64))
1930                    .ok_or(FormatError::InvalidArchive("tar octal field overflow"))?;
1931            }
1932            _ => return Err(FormatError::InvalidArchive("malformed tar octal field")),
1933        }
1934    }
1935    Ok(value)
1936}
1937
1938fn parse_decimal(field: &[u8]) -> Result<usize, FormatError> {
1939    let mut value = 0usize;
1940    if field.is_empty() {
1941        return Err(FormatError::InvalidArchive("malformed decimal field"));
1942    }
1943    for byte in field {
1944        if !byte.is_ascii_digit() {
1945            return Err(FormatError::InvalidArchive("malformed decimal field"));
1946        }
1947        value = value
1948            .checked_mul(10)
1949            .and_then(|acc| acc.checked_add((byte - b'0') as usize))
1950            .ok_or(FormatError::InvalidArchive("decimal field overflow"))?;
1951    }
1952    Ok(value)
1953}
1954
1955fn nul_trimmed(bytes: &[u8]) -> &[u8] {
1956    let end = bytes
1957        .iter()
1958        .position(|byte| *byte == 0)
1959        .unwrap_or(bytes.len());
1960    &bytes[..end]
1961}
1962
1963fn padding_to_512(len: usize) -> usize {
1964    let remainder = len % TAR_BLOCK_LEN;
1965    if remainder == 0 {
1966        0
1967    } else {
1968        TAR_BLOCK_LEN - remainder
1969    }
1970}
1971
1972fn padding_to_512_u64(len: u64) -> u64 {
1973    let remainder = len % TAR_BLOCK_LEN as u64;
1974    if remainder == 0 {
1975        0
1976    } else {
1977        TAR_BLOCK_LEN as u64 - remainder
1978    }
1979}
1980
1981fn slice(bytes: &[u8], offset: usize, len: usize) -> Result<&[u8], FormatError> {
1982    let end = checked_add(offset, len)?;
1983    bytes.get(offset..end).ok_or(FormatError::InvalidLength {
1984        structure: "tar member",
1985        expected: end,
1986        actual: bytes.len(),
1987    })
1988}
1989
1990fn checked_add(lhs: usize, rhs: usize) -> Result<usize, FormatError> {
1991    lhs.checked_add(rhs).ok_or(FormatError::InvalidArchive(
1992        "tar member arithmetic overflow",
1993    ))
1994}
1995
1996fn to_usize(value: u64) -> Result<usize, FormatError> {
1997    usize::try_from(value).map_err(|_| FormatError::InvalidArchive("tar member size overflow"))
1998}
1999
2000#[cfg(test)]
2001mod tests {
2002    use super::*;
2003    use tempfile::tempdir;
2004
2005    fn header(path: &[u8], kind: u8, size: usize, link: &[u8]) -> [u8; TAR_BLOCK_LEN] {
2006        let mut header = [0u8; TAR_BLOCK_LEN];
2007        header[..path.len()].copy_from_slice(path);
2008        write_octal(&mut header[100..108], 0o644);
2009        write_octal(&mut header[108..116], 0);
2010        write_octal(&mut header[116..124], 0);
2011        write_octal(&mut header[124..136], size as u64);
2012        write_octal(&mut header[136..148], 0);
2013        header[148..156].fill(b' ');
2014        header[156] = kind;
2015        header[157..157 + link.len()].copy_from_slice(link);
2016        header[257..263].copy_from_slice(b"ustar\0");
2017        header[263..265].copy_from_slice(b"00");
2018        let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
2019        write_checksum(&mut header[148..156], checksum);
2020        header
2021    }
2022
2023    fn member(path: &[u8], kind: u8, data: &[u8], link: &[u8]) -> Vec<u8> {
2024        member_with_declared_size(path, kind, data.len(), data, link)
2025    }
2026
2027    fn member_with_declared_size(
2028        path: &[u8],
2029        kind: u8,
2030        declared_size: usize,
2031        data: &[u8],
2032        link: &[u8],
2033    ) -> Vec<u8> {
2034        let mut out = Vec::new();
2035        out.extend_from_slice(&header(path, kind, declared_size, link));
2036        out.extend_from_slice(data);
2037        out.resize(out.len() + padding_to_512(data.len()), 0);
2038        out
2039    }
2040
2041    fn member_with_prefix(prefix: &[u8], path: &[u8], kind: u8, data: &[u8]) -> Vec<u8> {
2042        let mut header = header(path, kind, data.len(), b"");
2043        header[345..345 + prefix.len()].copy_from_slice(prefix);
2044        header[148..156].fill(b' ');
2045        let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
2046        write_checksum(&mut header[148..156], checksum);
2047
2048        let mut out = Vec::new();
2049        out.extend_from_slice(&header);
2050        out.extend_from_slice(data);
2051        out.resize(out.len() + padding_to_512(data.len()), 0);
2052        out
2053    }
2054
2055    fn pax_record(key: &str, value: &[u8]) -> Vec<u8> {
2056        let mut len = key.len() + value.len() + 4;
2057        loop {
2058            let candidate = len.to_string().len() + 1 + key.len() + 1 + value.len() + 1;
2059            if candidate == len {
2060                break;
2061            }
2062            len = candidate;
2063        }
2064        let mut out = Vec::new();
2065        out.extend_from_slice(len.to_string().as_bytes());
2066        out.push(b' ');
2067        out.extend_from_slice(key.as_bytes());
2068        out.push(b'=');
2069        out.extend_from_slice(value);
2070        out.push(b'\n');
2071        out
2072    }
2073
2074    fn write_octal(field: &mut [u8], value: u64) {
2075        let digits = format!("{value:o}");
2076        field.fill(0);
2077        let start = field.len() - 1 - digits.len();
2078        field[..start].fill(b'0');
2079        field[start..start + digits.len()].copy_from_slice(digits.as_bytes());
2080    }
2081
2082    fn write_checksum(field: &mut [u8], value: u64) {
2083        let digits = format!("{value:06o}");
2084        field[0..6].copy_from_slice(digits.as_bytes());
2085        field[6] = 0;
2086        field[7] = b' ';
2087    }
2088
2089    #[test]
2090    fn parses_ustar_regular_member() {
2091        let bytes = member(b"dir/file.txt", b'0', b"hello", b"");
2092        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2093
2094        assert_eq!(parsed.kind, TarEntryKind::Regular);
2095        assert_eq!(parsed.path, b"dir/file.txt");
2096        assert_eq!(parsed.data, b"hello");
2097        assert_eq!(parsed.logical_size, 5);
2098    }
2099
2100    #[test]
2101    fn canonicalizes_one_directory_trailing_slash_only_for_directories() {
2102        let dir = member(b"dir/", b'5', b"", b"");
2103        assert_eq!(parse_tar_member_group(&dir, 4096).unwrap().path, b"dir");
2104
2105        let file = member(b"dir/", b'0', b"", b"");
2106        assert_eq!(
2107            parse_tar_member_group(&file, 4096).unwrap_err(),
2108            FormatError::UnsafeArchivePath
2109        );
2110    }
2111
2112    #[test]
2113    fn rejects_global_pax_headers() {
2114        let bytes = member(b"pax", b'g', b"11 path=x\n", b"");
2115        assert_eq!(
2116            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2117            FormatError::InvalidArchive("global PAX headers are not allowed")
2118        );
2119    }
2120
2121    #[test]
2122    fn rejects_global_pax_before_main_entry() {
2123        let global_pax = pax_record("path", b"poisoned.txt");
2124        let mut bytes = member(b"GlobalHead/path", b'g', &global_pax, b"");
2125        bytes.extend_from_slice(&member(b"safe.txt", b'0', b"abc", b""));
2126
2127        assert_eq!(
2128            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2129            FormatError::InvalidArchive("global PAX headers are not allowed")
2130        );
2131    }
2132
2133    #[test]
2134    fn rejects_global_gnu_headers() {
2135        for typeflag in [b'V', b'M', b'N'] {
2136            let bytes = member(b"global", typeflag, b"archive-label", b"");
2137
2138            assert_eq!(
2139                parse_tar_member_group(&bytes, 4096).unwrap_err(),
2140                FormatError::InvalidArchive("global GNU headers are not allowed"),
2141                "typeflag {typeflag:?}"
2142            );
2143        }
2144    }
2145
2146    #[test]
2147    fn rejects_unsupported_gnu_sparse_entry_type() {
2148        let bytes = member(b"sparse.bin", b'S', b"", b"");
2149
2150        assert_eq!(
2151            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2152            FormatError::ReaderUnsupported("unsupported GNU sparse tar entry")
2153        );
2154    }
2155
2156    #[test]
2157    fn applies_local_pax_path_and_size() {
2158        let pax = pax_record("path", b"long/name.txt");
2159        let mut bytes = member(b"PaxHeaders/name", b'x', &pax, b"");
2160        bytes.extend_from_slice(&member(b"short", b'0', b"abc", b""));
2161
2162        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2163        assert_eq!(parsed.path, b"long/name.txt");
2164        assert_eq!(parsed.data, b"abc");
2165    }
2166
2167    #[test]
2168    fn applies_local_gnu_long_name_and_link_to_following_entry() {
2169        let mut named = member(b"././@LongLink", b'L', b"long/path.txt\0", b"");
2170        named.extend_from_slice(&member(b"short", b'0', b"abc", b""));
2171        let parsed = parse_tar_member_group(&named, 4096).unwrap();
2172        assert_eq!(parsed.path, b"long/path.txt");
2173        assert_eq!(parsed.data, b"abc");
2174
2175        let mut linked = member(b"././@LongLink", b'K', b"target/file.txt\0", b"");
2176        linked.extend_from_slice(&member(b"short-link", b'2', b"", b"fallback"));
2177        let parsed = parse_tar_member_group(&linked, 4096).unwrap();
2178        assert_eq!(parsed.path, b"short-link");
2179        assert_eq!(
2180            parsed.link_target.as_deref(),
2181            Some(b"target/file.txt".as_slice())
2182        );
2183    }
2184
2185    #[test]
2186    fn supported_tar_metadata_profile_matrix_matches_buffered_and_streaming_parsers() {
2187        struct Case {
2188            name: &'static str,
2189            bytes: Vec<u8>,
2190            expected_path: &'static [u8],
2191            expected_kind: TarEntryKind,
2192            expected_data: &'static [u8],
2193            expected_link_target: Option<&'static [u8]>,
2194            expected_logical_size: u64,
2195        }
2196
2197        let mut pax_path_and_size = member(
2198            b"PaxHeaders/pax-file",
2199            b'x',
2200            &[
2201                pax_record("path", b"deep/pax-name.txt"),
2202                pax_record("size", b"5"),
2203            ]
2204            .concat(),
2205            b"",
2206        );
2207        pax_path_and_size.extend_from_slice(&member_with_declared_size(
2208            b"fallback",
2209            b'0',
2210            0,
2211            b"hello",
2212            b"",
2213        ));
2214
2215        let mut pax_linkpath = member(
2216            b"PaxHeaders/pax-link",
2217            b'x',
2218            &pax_record("linkpath", b"target/file.txt"),
2219            b"",
2220        );
2221        pax_linkpath.extend_from_slice(&member(b"links/link", b'2', b"", b"fallback-target"));
2222
2223        let mut gnu_long_name = member(b"././@LongLink", b'L', b"long/path/name.txt\0", b"");
2224        gnu_long_name.extend_from_slice(&member(b"short", b'0', b"abc", b""));
2225
2226        let mut gnu_long_link = member(b"././@LongLink", b'K', b"target/hard.txt\0", b"");
2227        gnu_long_link.extend_from_slice(&member(b"links/hard", b'1', b"", b"fallback"));
2228
2229        let cases = vec![
2230            Case {
2231                name: "regular ustar member",
2232                bytes: member(b"dir/file.txt", b'0', b"hello", b""),
2233                expected_path: b"dir/file.txt",
2234                expected_kind: TarEntryKind::Regular,
2235                expected_data: b"hello",
2236                expected_link_target: None,
2237                expected_logical_size: 5,
2238            },
2239            Case {
2240                name: "ustar prefix plus name",
2241                bytes: member_with_prefix(b"dir/prefix", b"file.txt", b'0', b"abc"),
2242                expected_path: b"dir/prefix/file.txt",
2243                expected_kind: TarEntryKind::Regular,
2244                expected_data: b"abc",
2245                expected_link_target: None,
2246                expected_logical_size: 3,
2247            },
2248            Case {
2249                name: "directory trailing slash",
2250                bytes: member(b"dir/", b'5', b"", b""),
2251                expected_path: b"dir",
2252                expected_kind: TarEntryKind::Directory,
2253                expected_data: b"",
2254                expected_link_target: None,
2255                expected_logical_size: 0,
2256            },
2257            Case {
2258                name: "local pax path and size",
2259                bytes: pax_path_and_size,
2260                expected_path: b"deep/pax-name.txt",
2261                expected_kind: TarEntryKind::Regular,
2262                expected_data: b"hello",
2263                expected_link_target: None,
2264                expected_logical_size: 5,
2265            },
2266            Case {
2267                name: "local pax linkpath",
2268                bytes: pax_linkpath,
2269                expected_path: b"links/link",
2270                expected_kind: TarEntryKind::Symlink,
2271                expected_data: b"",
2272                expected_link_target: Some(b"target/file.txt"),
2273                expected_logical_size: 0,
2274            },
2275            Case {
2276                name: "gnu long name",
2277                bytes: gnu_long_name,
2278                expected_path: b"long/path/name.txt",
2279                expected_kind: TarEntryKind::Regular,
2280                expected_data: b"abc",
2281                expected_link_target: None,
2282                expected_logical_size: 3,
2283            },
2284            Case {
2285                name: "gnu long link",
2286                bytes: gnu_long_link,
2287                expected_path: b"links/hard",
2288                expected_kind: TarEntryKind::Hardlink,
2289                expected_data: b"",
2290                expected_link_target: Some(b"target/hard.txt"),
2291                expected_logical_size: 0,
2292            },
2293        ];
2294
2295        for case in cases {
2296            let parsed = parse_tar_member_group(&case.bytes, 4096).unwrap_or_else(|err| {
2297                panic!("{} should parse in buffered tar parser: {err:?}", case.name)
2298            });
2299            assert_eq!(parsed.path, case.expected_path, "{}", case.name);
2300            assert_eq!(parsed.kind, case.expected_kind, "{}", case.name);
2301            assert_eq!(parsed.data, case.expected_data, "{}", case.name);
2302            assert_eq!(
2303                parsed.link_target.as_deref(),
2304                case.expected_link_target,
2305                "{}",
2306                case.name
2307            );
2308            assert_eq!(
2309                parsed.logical_size, case.expected_logical_size,
2310                "{}",
2311                case.name
2312            );
2313
2314            let mut streaming = TarStreamSummaryValidator::with_observer(
2315                4096,
2316                u64::MAX,
2317                4096,
2318                16,
2319                NoopTarStreamObserver,
2320            );
2321            streaming.observe(&case.bytes).unwrap_or_else(|err| {
2322                panic!(
2323                    "{} should parse in streaming tar parser: {err:?}",
2324                    case.name
2325                )
2326            });
2327            let summary = streaming.finish().unwrap_or_else(|err| {
2328                panic!(
2329                    "{} should finish in streaming tar parser: {err:?}",
2330                    case.name
2331                )
2332            });
2333            assert_eq!(summary.members.len(), 1, "{}", case.name);
2334            let member = &summary.members[0];
2335            assert_eq!(member.path, case.expected_path, "{}", case.name);
2336            assert_eq!(member.kind, case.expected_kind, "{}", case.name);
2337            assert_eq!(
2338                member.link_target.as_deref(),
2339                case.expected_link_target,
2340                "{}",
2341                case.name
2342            );
2343            assert_eq!(
2344                member.logical_size, case.expected_logical_size,
2345                "{}",
2346                case.name
2347            );
2348        }
2349    }
2350
2351    #[test]
2352    fn tar_metadata_rejects_unsafe_or_inconsistent_overrides_matrix() {
2353        let mut pax_absolute_path = member(
2354            b"PaxHeaders/file",
2355            b'x',
2356            &pax_record("path", b"/absolute"),
2357            b"",
2358        );
2359        pax_absolute_path.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
2360
2361        let mut pax_parent_path = member(
2362            b"PaxHeaders/file",
2363            b'x',
2364            &pax_record("path", b"../escape"),
2365            b"",
2366        );
2367        pax_parent_path.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
2368
2369        let mut pax_absolute_link = member(
2370            b"PaxHeaders/link",
2371            b'x',
2372            &pax_record("linkpath", b"/target"),
2373            b"",
2374        );
2375        pax_absolute_link.extend_from_slice(&member(b"links/link", b'2', b"", b"safe"));
2376
2377        let mut gnu_unsafe_name = member(b"././@LongLink", b'L', b"bad:name.txt\0", b"");
2378        gnu_unsafe_name.extend_from_slice(&member(b"fallback", b'0', b"abc", b""));
2379
2380        let mut gnu_parent_hardlink = member(b"././@LongLink", b'K', b"../target.txt\0", b"");
2381        gnu_parent_hardlink.extend_from_slice(&member(b"links/hard", b'1', b"", b"safe"));
2382
2383        let mut pax_size_on_directory =
2384            member(b"PaxHeaders/dir", b'x', &pax_record("size", b"1"), b"");
2385        pax_size_on_directory
2386            .extend_from_slice(&member_with_declared_size(b"dir", b'5', 0, b"x", b""));
2387
2388        for (name, bytes, expected) in [
2389            (
2390                "pax absolute path",
2391                pax_absolute_path,
2392                FormatError::UnsafeArchivePath,
2393            ),
2394            (
2395                "pax parent path",
2396                pax_parent_path,
2397                FormatError::UnsafeArchivePath,
2398            ),
2399            (
2400                "pax absolute symlink target",
2401                pax_absolute_link,
2402                FormatError::UnsafeArchivePath,
2403            ),
2404            (
2405                "gnu unsafe long name",
2406                gnu_unsafe_name,
2407                FormatError::UnsafeArchivePath,
2408            ),
2409            (
2410                "gnu hardlink parent target",
2411                gnu_parent_hardlink,
2412                FormatError::UnsafeArchivePath,
2413            ),
2414            (
2415                "pax size on directory",
2416                pax_size_on_directory,
2417                FormatError::InvalidArchive("non-regular tar entry has non-zero payload size"),
2418            ),
2419        ] {
2420            assert_eq!(
2421                parse_tar_member_group(&bytes, 4096).unwrap_err(),
2422                expected,
2423                "{name}"
2424            );
2425
2426            let mut streaming = TarStreamSummaryValidator::with_observer(
2427                4096,
2428                u64::MAX,
2429                4096,
2430                16,
2431                NoopTarStreamObserver,
2432            );
2433            assert_eq!(streaming.observe(&bytes).unwrap_err(), expected, "{name}");
2434        }
2435    }
2436
2437    #[test]
2438    fn pax_size_exceeding_available_group_is_rejected_by_buffered_and_streaming_parsers() {
2439        let mut bytes = member(b"PaxHeaders/file", b'x', &pax_record("size", b"4096"), b"");
2440        bytes.extend_from_slice(&member_with_declared_size(b"file", b'0', 0, b"short", b""));
2441
2442        assert_eq!(
2443            parse_tar_member_group(&bytes, 4096).unwrap_err(),
2444            FormatError::InvalidLength {
2445                structure: "tar member",
2446                expected: 5632,
2447                actual: 2048,
2448            }
2449        );
2450
2451        let mut streaming = TarStreamSummaryValidator::with_observer(
2452            4096,
2453            u64::MAX,
2454            4096,
2455            16,
2456            NoopTarStreamObserver,
2457        );
2458        streaming.observe(&bytes).unwrap();
2459        assert_eq!(
2460            streaming.finish().unwrap_err(),
2461            FormatError::InvalidArchive("tar stream ended inside member group")
2462        );
2463    }
2464
2465    #[test]
2466    fn malformed_pax_record_matrix_rejects_before_metadata_is_trusted() {
2467        let cases: Vec<(&str, Vec<u8>)> = vec![
2468            ("missing length", b"path=file\n".to_vec()),
2469            ("missing space", b"12path=file\n".to_vec()),
2470            ("record too short", b"3 a\n".to_vec()),
2471            ("missing newline", b"11 path=file".to_vec()),
2472            ("missing equals", b"10 pathfile\n".to_vec()),
2473            ("non utf8 key", vec![7, b' ', 0xff, b'=', b'x', b'\n']),
2474            ("bad size value", pax_record("size", b"12x")),
2475        ];
2476
2477        for (name, payload) in cases {
2478            let mut bytes = member(b"PaxHeaders/file", b'x', &payload, b"");
2479            bytes.extend_from_slice(&member(b"file", b'0', b"abc", b""));
2480
2481            assert!(
2482                matches!(
2483                    parse_tar_member_group(&bytes, 4096).unwrap_err(),
2484                    FormatError::InvalidArchive(_)
2485                ),
2486                "{name}"
2487            );
2488
2489            let mut streaming = TarStreamSummaryValidator::with_observer(
2490                4096,
2491                u64::MAX,
2492                4096,
2493                16,
2494                NoopTarStreamObserver,
2495            );
2496            assert!(
2497                matches!(
2498                    streaming.observe(&bytes).unwrap_err(),
2499                    FormatError::InvalidArchive(_)
2500                ),
2501                "{name}"
2502            );
2503        }
2504    }
2505
2506    #[test]
2507    fn reports_degraded_diagnostics_for_xattr_and_acl_pax_profiles() {
2508        let mut pax = Vec::new();
2509        pax.extend_from_slice(&pax_record("SCHILY.xattr.user.comment", b"hello"));
2510        pax.extend_from_slice(&pax_record("LIBARCHIVE.xattr.user.comment", b"hello"));
2511        pax.extend_from_slice(&pax_record("SCHILY.acl.access", b"user::rw-"));
2512        pax.extend_from_slice(&pax_record("LIBARCHIVE.acl.access", b"user::rw-"));
2513        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2514        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2515
2516        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2517
2518        assert_eq!(parsed.path, b"file.txt");
2519        assert_eq!(parsed.data, b"abc");
2520        assert_eq!(
2521            parsed
2522                .diagnostics
2523                .iter()
2524                .filter(|diagnostic| **diagnostic
2525                    == MetadataDiagnostic {
2526                        profile: "pax-xattrs-acls",
2527                        message: "unsupported xattr/ACL PAX metadata was ignored",
2528                    })
2529                .count(),
2530            4
2531        );
2532    }
2533
2534    #[test]
2535    fn reports_degraded_diagnostics_for_pax_timestamp_precision() {
2536        let mut pax = Vec::new();
2537        pax.extend_from_slice(&pax_record("atime", b"1.123456789"));
2538        pax.extend_from_slice(&pax_record("ctime", b"2.123456789"));
2539        pax.extend_from_slice(&pax_record("mtime", b"3.123456789"));
2540        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2541        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2542
2543        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2544
2545        assert_eq!(
2546            parsed
2547                .diagnostics
2548                .iter()
2549                .filter(|diagnostic| **diagnostic
2550                    == MetadataDiagnostic {
2551                        profile: "pax-posix-2001",
2552                        message: "unsupported PAX timestamp metadata was ignored",
2553                    })
2554                .count(),
2555            3
2556        );
2557    }
2558
2559    #[test]
2560    fn reports_degraded_diagnostics_for_sparse_and_unknown_pax_profiles() {
2561        let mut pax = Vec::new();
2562        pax.extend_from_slice(&pax_record("GNU.sparse.realsize", b"1024"));
2563        pax.extend_from_slice(&pax_record("GNU.sparse.map", b"0,1"));
2564        pax.extend_from_slice(&pax_record("comment", b"ignored"));
2565        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2566        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2567
2568        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2569
2570        assert_eq!(
2571            parsed
2572                .diagnostics
2573                .iter()
2574                .filter(|diagnostic| **diagnostic
2575                    == MetadataDiagnostic {
2576                        profile: "gnu-sparse",
2577                        message: "unsupported sparse-file PAX metadata was ignored",
2578                    })
2579                .count(),
2580            2
2581        );
2582        assert_eq!(
2583            parsed
2584                .diagnostics
2585                .iter()
2586                .filter(|diagnostic| **diagnostic
2587                    == MetadataDiagnostic {
2588                        profile: "pax-posix-2001",
2589                        message: "unsupported PAX key was ignored",
2590                    })
2591                .count(),
2592            1
2593        );
2594    }
2595
2596    #[test]
2597    fn reports_degraded_diagnostics_for_unsupported_local_pax_profiles() {
2598        let mut pax = Vec::new();
2599        pax.extend_from_slice(&pax_record("SCHILY.xattr.user.comment", b"hello"));
2600        pax.extend_from_slice(&pax_record("GNU.sparse.realsize", b"1024"));
2601        pax.extend_from_slice(&pax_record("mtime", b"1.123456789"));
2602        pax.extend_from_slice(&pax_record("comment", b"ignored"));
2603        let mut bytes = member(b"PaxHeaders/file", b'x', &pax, b"");
2604        bytes.extend_from_slice(&member(b"file.txt", b'0', b"abc", b""));
2605
2606        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
2607
2608        assert_eq!(parsed.path, b"file.txt");
2609        assert_eq!(parsed.data, b"abc");
2610        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2611            profile: "pax-xattrs-acls",
2612            message: "unsupported xattr/ACL PAX metadata was ignored",
2613        }));
2614        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2615            profile: "gnu-sparse",
2616            message: "unsupported sparse-file PAX metadata was ignored",
2617        }));
2618        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2619            profile: "pax-posix-2001",
2620            message: "unsupported PAX timestamp metadata was ignored",
2621        }));
2622        assert!(parsed.diagnostics.contains(&MetadataDiagnostic {
2623            profile: "pax-posix-2001",
2624            message: "unsupported PAX key was ignored",
2625        }));
2626    }
2627
2628    #[test]
2629    fn rejects_platform_escape_paths() {
2630        for path in [
2631            b"/abs".as_slice(),
2632            b"../up".as_slice(),
2633            b"a//b".as_slice(),
2634            b"a\\b".as_slice(),
2635            b"a:b".as_slice(),
2636            b"CON".as_slice(),
2637        ] {
2638            let bytes = member(path, b'0', b"", b"");
2639            assert_eq!(
2640                parse_tar_member_group(&bytes, 4096).unwrap_err(),
2641                FormatError::UnsafeArchivePath
2642            );
2643        }
2644    }
2645
2646    #[cfg(unix)]
2647    #[test]
2648    fn safe_restore_rejects_symlink_parent() {
2649        let tmp = tempdir().unwrap();
2650        let outside = tempdir().unwrap();
2651        std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap();
2652
2653        let member = OwnedTarMember {
2654            path: b"link/file.txt".to_vec(),
2655            kind: TarEntryKind::Regular,
2656            data: b"blocked".to_vec(),
2657            link_target: None,
2658            mode: 0o644,
2659            mtime: 0,
2660            logical_size: 7,
2661            diagnostics: Vec::new(),
2662        };
2663
2664        assert_eq!(
2665            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap_err(),
2666            FormatError::UnsafeArchivePath
2667        );
2668    }
2669
2670    #[test]
2671    fn safe_restore_requires_hardlink_target_to_be_existing_regular_file() {
2672        let tmp = tempdir().unwrap();
2673        fs::write(tmp.path().join("target.txt"), b"target").unwrap();
2674        let member = OwnedTarMember {
2675            path: b"linked.txt".to_vec(),
2676            kind: TarEntryKind::Hardlink,
2677            data: Vec::new(),
2678            link_target: Some(b"target.txt".to_vec()),
2679            mode: 0o644,
2680            mtime: 0,
2681            logical_size: 0,
2682            diagnostics: Vec::new(),
2683        };
2684
2685        restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2686        assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
2687    }
2688
2689    #[cfg(unix)]
2690    #[test]
2691    fn restore_applies_regular_file_mode_metadata() {
2692        let tmp = tempdir().unwrap();
2693        let member = OwnedTarMember {
2694            path: b"script.sh".to_vec(),
2695            kind: TarEntryKind::Regular,
2696            data: b"#!/bin/sh\n".to_vec(),
2697            link_target: None,
2698            mode: 0o755,
2699            mtime: 0,
2700            logical_size: 10,
2701            diagnostics: Vec::new(),
2702        };
2703
2704        let diagnostics =
2705            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2706
2707        assert!(diagnostics.is_empty());
2708        let mode = fs::metadata(tmp.path().join("script.sh"))
2709            .unwrap()
2710            .permissions()
2711            .mode()
2712            & 0o777;
2713        assert_eq!(mode, 0o755);
2714    }
2715
2716    #[test]
2717    fn restore_applies_regular_file_mtime_metadata() {
2718        let tmp = tempdir().unwrap();
2719        let member = OwnedTarMember {
2720            path: b"dated.txt".to_vec(),
2721            kind: TarEntryKind::Regular,
2722            data: b"dated".to_vec(),
2723            link_target: None,
2724            mode: 0o666,
2725            mtime: 1_700_000_000,
2726            logical_size: 5,
2727            diagnostics: Vec::new(),
2728        };
2729
2730        let diagnostics =
2731            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2732
2733        assert!(diagnostics.is_empty());
2734        let modified = fs::metadata(tmp.path().join("dated.txt"))
2735            .unwrap()
2736            .modified()
2737            .unwrap()
2738            .duration_since(SystemTime::UNIX_EPOCH)
2739            .unwrap()
2740            .as_secs();
2741        assert_eq!(modified, 1_700_000_000);
2742    }
2743
2744    #[test]
2745    fn restore_revalidates_symlink_targets_from_owned_members() {
2746        let tmp = tempdir().unwrap();
2747        let member = OwnedTarMember {
2748            path: b"link".to_vec(),
2749            kind: TarEntryKind::Symlink,
2750            data: Vec::new(),
2751            link_target: Some(b"/outside".to_vec()),
2752            mode: 0o644,
2753            mtime: 0,
2754            logical_size: 0,
2755            diagnostics: Vec::new(),
2756        };
2757
2758        assert_eq!(
2759            restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap_err(),
2760            FormatError::UnsafeArchivePath
2761        );
2762        assert!(!tmp.path().join("link").exists());
2763    }
2764
2765    #[test]
2766    fn safe_restore_rejects_directory_over_existing_file_even_with_overwrite() {
2767        let tmp = tempdir().unwrap();
2768        let conflict = tmp.path().join("conflict");
2769        fs::write(&conflict, b"not a directory").unwrap();
2770        let member = OwnedTarMember {
2771            path: b"conflict".to_vec(),
2772            kind: TarEntryKind::Directory,
2773            data: Vec::new(),
2774            link_target: None,
2775            mode: 0o644,
2776            mtime: 0,
2777            logical_size: 0,
2778            diagnostics: Vec::new(),
2779        };
2780
2781        assert_eq!(
2782            restore_tar_member(
2783                tmp.path(),
2784                &member,
2785                SafeExtractionOptions {
2786                    overwrite_existing: true
2787                }
2788            )
2789            .unwrap_err(),
2790            FormatError::UnsafeOverwrite
2791        );
2792        assert!(conflict.is_file());
2793    }
2794
2795    #[test]
2796    fn hardlink_target_checks_use_component_position_not_value() {
2797        let tmp = tempdir().unwrap();
2798        fs::create_dir(tmp.path().join("a")).unwrap();
2799        fs::write(tmp.path().join("a").join("a"), b"target").unwrap();
2800        let member = OwnedTarMember {
2801            path: b"linked.txt".to_vec(),
2802            kind: TarEntryKind::Hardlink,
2803            data: Vec::new(),
2804            link_target: Some(b"a/a".to_vec()),
2805            mode: 0o644,
2806            mtime: 0,
2807            logical_size: 0,
2808            diagnostics: Vec::new(),
2809        };
2810
2811        restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
2812        assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
2813    }
2814
2815    #[test]
2816    fn hardlink_targets_obey_max_path_length() {
2817        let bytes = member(b"link", b'1', b"", b"long/name");
2818
2819        assert_eq!(
2820            parse_tar_member_group(&bytes, 4).unwrap_err(),
2821            FormatError::UnsafeArchivePath
2822        );
2823    }
2824}