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