1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use crate::format::FormatError;
6use crate::metadata::validate_file_path_bytes;
7
8const TAR_BLOCK_LEN: usize = 512;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum TarEntryKind {
12 Regular,
13 Directory,
14 Symlink,
15 Hardlink,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MetadataDiagnostic {
20 pub profile: &'static str,
21 pub message: &'static str,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct OwnedTarMember {
26 pub path: Vec<u8>,
27 pub kind: TarEntryKind,
28 pub data: Vec<u8>,
29 pub link_target: Option<Vec<u8>>,
30 pub mode: u32,
31 pub mtime: u64,
32 pub logical_size: u64,
33 pub diagnostics: Vec<MetadataDiagnostic>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ParsedTarMember<'a> {
38 pub path: Vec<u8>,
39 pub kind: TarEntryKind,
40 pub data: &'a [u8],
41 pub link_target: Option<Vec<u8>>,
42 pub mode: u32,
43 pub mtime: u64,
44 pub logical_size: u64,
45 pub diagnostics: Vec<MetadataDiagnostic>,
46}
47
48impl ParsedTarMember<'_> {
49 pub fn to_owned_member(&self) -> OwnedTarMember {
50 OwnedTarMember {
51 path: self.path.clone(),
52 kind: self.kind,
53 data: self.data.to_vec(),
54 link_target: self.link_target.clone(),
55 mode: self.mode,
56 mtime: self.mtime,
57 logical_size: self.logical_size,
58 diagnostics: self.diagnostics.clone(),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct SafeExtractionOptions {
65 pub overwrite_existing: bool,
66}
67
68impl Default for SafeExtractionOptions {
69 fn default() -> Self {
70 Self {
71 overwrite_existing: false,
72 }
73 }
74}
75
76#[derive(Default)]
77struct LocalMetadata {
78 pax_path: Option<Vec<u8>>,
79 pax_linkpath: Option<Vec<u8>>,
80 pax_size: Option<u64>,
81 gnu_long_name: Option<Vec<u8>>,
82 gnu_long_link: Option<Vec<u8>>,
83 diagnostics: Vec<MetadataDiagnostic>,
84}
85
86pub fn parse_tar_member_group<'a>(
87 group: &'a [u8],
88 max_path_length: u32,
89) -> Result<ParsedTarMember<'a>, FormatError> {
90 if group.len() < TAR_BLOCK_LEN || group.len() % TAR_BLOCK_LEN != 0 {
91 return Err(FormatError::InvalidArchive(
92 "tar member group is not block aligned",
93 ));
94 }
95
96 let mut cursor = 0usize;
97 let mut metadata = LocalMetadata::default();
98
99 loop {
100 let header = slice(group, cursor, TAR_BLOCK_LEN)?;
101 if header.iter().all(|byte| *byte == 0) {
102 return Err(FormatError::InvalidArchive("tar member header is empty"));
103 }
104 verify_tar_checksum(header)?;
105 let typeflag = header[156];
106 let header_size = parse_tar_octal(&header[124..136])?;
107 let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
108 let effective_size = if is_main {
109 metadata.pax_size.unwrap_or(header_size)
110 } else {
111 header_size
112 };
113 let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
114 let payload_len = to_usize(effective_size)?;
115 let payload_end = checked_add(payload_start, payload_len)?;
116 let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
117 let payload = slice(group, payload_start, payload_len)?;
118 if padded_end > group.len() {
119 return Err(FormatError::InvalidArchive(
120 "tar member payload exceeds group",
121 ));
122 }
123 if group[payload_end..padded_end].iter().any(|byte| *byte != 0) {
124 return Err(FormatError::InvalidArchive(
125 "tar member padding is non-zero",
126 ));
127 }
128
129 match typeflag {
130 b'x' => {
131 parse_pax_records(payload, &mut metadata)?;
132 cursor = padded_end;
133 }
134 b'g' => {
135 return Err(FormatError::InvalidArchive(
136 "global PAX headers are not allowed",
137 ));
138 }
139 b'L' => {
140 metadata.gnu_long_name = Some(trimmed_metadata_payload(payload));
141 cursor = padded_end;
142 }
143 b'K' => {
144 metadata.gnu_long_link = Some(trimmed_metadata_payload(payload));
145 cursor = padded_end;
146 }
147 0 | b'0' | b'5' | b'2' | b'1' => {
148 if padded_end != group.len() {
149 return Err(FormatError::InvalidArchive(
150 "tar member group has bytes after main entry",
151 ));
152 }
153 let kind = match typeflag {
154 b'5' => TarEntryKind::Directory,
155 b'2' => TarEntryKind::Symlink,
156 b'1' => TarEntryKind::Hardlink,
157 _ => TarEntryKind::Regular,
158 };
159 let mode = parse_tar_octal(&header[100..108])? as u32;
160 let mtime = parse_tar_octal(&header[136..148])?;
161 let path = canonical_main_path(header, kind, &metadata, max_path_length)?;
162 let link_target =
163 canonical_link_target(header, kind, &path, &metadata, max_path_length)?;
164 if kind != TarEntryKind::Regular && effective_size != 0 {
165 return Err(FormatError::InvalidArchive(
166 "non-regular tar entry has non-zero payload size",
167 ));
168 }
169 let logical_size = if kind == TarEntryKind::Regular {
170 effective_size
171 } else {
172 0
173 };
174 return Ok(ParsedTarMember {
175 path,
176 kind,
177 data: if kind == TarEntryKind::Regular {
178 payload
179 } else {
180 &[]
181 },
182 mode,
183 mtime,
184 link_target,
185 logical_size,
186 diagnostics: metadata.diagnostics,
187 });
188 }
189 _ => {
190 return Err(FormatError::ReaderUnsupported("unsupported tar entry type"));
191 }
192 }
193
194 if cursor >= group.len() {
195 return Err(FormatError::InvalidArchive(
196 "tar member group has metadata records but no main entry",
197 ));
198 }
199 }
200}
201
202pub fn validate_tar_stream_total_extraction_size(
203 stream: &[u8],
204 max_path_length: u32,
205 cap: u64,
206) -> Result<(), FormatError> {
207 if stream.len() % TAR_BLOCK_LEN != 0 {
208 return Err(FormatError::InvalidArchive(
209 "tar stream is not block aligned",
210 ));
211 }
212
213 let mut cursor = 0usize;
214 let mut total = 0u64;
215 while cursor < stream.len() {
216 let group_end = tar_member_group_end(stream, cursor)?;
217 let member = parse_tar_member_group(&stream[cursor..group_end], max_path_length)?;
218 if member.kind == TarEntryKind::Regular {
219 total = total
220 .checked_add(member.logical_size)
221 .ok_or(FormatError::InvalidArchive(
222 "total extraction size overflow",
223 ))?;
224 if total > cap {
225 return Err(FormatError::ReaderUnsupported(
226 "total extraction size exceeds configured cap",
227 ));
228 }
229 }
230 cursor = group_end;
231 }
232 Ok(())
233}
234
235fn tar_member_group_end(stream: &[u8], start: usize) -> Result<usize, FormatError> {
236 let mut cursor = start;
237 let mut metadata = LocalMetadata::default();
238
239 loop {
240 let header = slice(stream, cursor, TAR_BLOCK_LEN)?;
241 if header.iter().all(|byte| *byte == 0) {
242 return Err(FormatError::InvalidArchive("tar member header is empty"));
243 }
244 verify_tar_checksum(header)?;
245 let typeflag = header[156];
246 let header_size = parse_tar_octal(&header[124..136])?;
247 let is_main = matches!(typeflag, 0 | b'0' | b'5' | b'2' | b'1');
248 let effective_size = if is_main {
249 metadata.pax_size.unwrap_or(header_size)
250 } else {
251 header_size
252 };
253 let payload_start = checked_add(cursor, TAR_BLOCK_LEN)?;
254 let payload_len = to_usize(effective_size)?;
255 let payload_end = checked_add(payload_start, payload_len)?;
256 let padded_end = checked_add(payload_end, padding_to_512(payload_len))?;
257 let payload = slice(stream, payload_start, payload_len)?;
258 if padded_end > stream.len() {
259 return Err(FormatError::InvalidArchive(
260 "tar member payload exceeds stream",
261 ));
262 }
263 if stream[payload_end..padded_end]
264 .iter()
265 .any(|byte| *byte != 0)
266 {
267 return Err(FormatError::InvalidArchive(
268 "tar member padding is non-zero",
269 ));
270 }
271
272 match typeflag {
273 b'x' => {
274 parse_pax_records(payload, &mut metadata)?;
275 cursor = padded_end;
276 }
277 b'L' | b'K' => {
278 cursor = padded_end;
279 }
280 b'g' => {
281 return Err(FormatError::InvalidArchive(
282 "global PAX headers are not allowed",
283 ));
284 }
285 0 | b'0' | b'5' | b'2' | b'1' => return Ok(padded_end),
286 _ => return Err(FormatError::ReaderUnsupported("unsupported tar entry type")),
287 }
288
289 if cursor >= stream.len() {
290 return Err(FormatError::InvalidArchive(
291 "tar member group has metadata records but no main entry",
292 ));
293 }
294 }
295}
296
297pub fn restore_tar_member(
298 root: &Path,
299 member: &OwnedTarMember,
300 options: SafeExtractionOptions,
301) -> Result<Vec<MetadataDiagnostic>, FormatError> {
302 let destination = prepare_destination(root, &member.path, member.kind, options)?;
303 match member.kind {
304 TarEntryKind::Regular => write_regular_file(&destination, &member.data, options)?,
305 TarEntryKind::Directory => create_directory(&destination)?,
306 TarEntryKind::Symlink => {
307 let target = member
308 .link_target
309 .as_deref()
310 .ok_or(FormatError::InvalidArchive("symlink target is missing"))?;
311 validate_symlink_target(&member.path, target)?;
312 create_symlink(&destination, target, options)?;
313 }
314 TarEntryKind::Hardlink => {
315 let target = member
316 .link_target
317 .as_deref()
318 .ok_or(FormatError::InvalidArchive("hardlink target is missing"))?;
319 let target_path = existing_safe_regular_path(root, target)?;
320 create_hardlink(&destination, &target_path, options)?;
321 }
322 }
323 Ok(member.diagnostics.clone())
324}
325
326fn canonical_main_path(
327 header: &[u8],
328 kind: TarEntryKind,
329 metadata: &LocalMetadata,
330 max_path_length: u32,
331) -> Result<Vec<u8>, FormatError> {
332 let mut path = metadata
333 .pax_path
334 .clone()
335 .or_else(|| metadata.gnu_long_name.clone())
336 .unwrap_or_else(|| ustar_path(header));
337 if kind == TarEntryKind::Directory && path.ends_with(b"/") && !path.ends_with(b"//") {
338 path.pop();
339 }
340 validate_file_path_bytes(&path, max_path_length)?;
341 Ok(path)
342}
343
344fn canonical_link_target(
345 header: &[u8],
346 kind: TarEntryKind,
347 link_path: &[u8],
348 metadata: &LocalMetadata,
349 max_path_length: u32,
350) -> Result<Option<Vec<u8>>, FormatError> {
351 if !matches!(kind, TarEntryKind::Symlink | TarEntryKind::Hardlink) {
352 return Ok(None);
353 }
354 let target = metadata
355 .pax_linkpath
356 .clone()
357 .or_else(|| metadata.gnu_long_link.clone())
358 .unwrap_or_else(|| nul_trimmed(&header[157..257]).to_vec());
359 if target.is_empty() {
360 return Err(FormatError::UnsafeArchivePath);
361 }
362 match kind {
363 TarEntryKind::Hardlink => validate_file_path_bytes(&target, max_path_length)?,
364 TarEntryKind::Symlink => validate_symlink_target(link_path, &target)?,
365 _ => {}
366 }
367 Ok(Some(target))
368}
369
370fn parse_pax_records(payload: &[u8], metadata: &mut LocalMetadata) -> Result<(), FormatError> {
371 let mut cursor = 0usize;
372 while cursor < payload.len() {
373 let len_digits_start = cursor;
374 while cursor < payload.len() && payload[cursor].is_ascii_digit() {
375 cursor += 1;
376 }
377 if cursor == len_digits_start || cursor >= payload.len() || payload[cursor] != b' ' {
378 return Err(FormatError::InvalidArchive("malformed PAX record"));
379 }
380 let len = parse_decimal(&payload[len_digits_start..cursor])?;
381 let record_start = len_digits_start;
382 let record_end = checked_add(record_start, len)?;
383 if record_end > payload.len() || len < 4 {
384 return Err(FormatError::InvalidArchive("malformed PAX record"));
385 }
386 let body_start = cursor + 1;
387 let record = &payload[body_start..record_end];
388 if record.last().copied() != Some(b'\n') {
389 return Err(FormatError::InvalidArchive("malformed PAX record"));
390 }
391 let body = &record[..record.len() - 1];
392 let eq = body
393 .iter()
394 .position(|byte| *byte == b'=')
395 .ok_or(FormatError::InvalidArchive("malformed PAX record"))?;
396 let key = std::str::from_utf8(&body[..eq])
397 .map_err(|_| FormatError::InvalidArchive("malformed PAX key"))?;
398 let value = &body[eq + 1..];
399 match key {
400 "path" => metadata.pax_path = Some(value.to_vec()),
401 "linkpath" => metadata.pax_linkpath = Some(value.to_vec()),
402 "size" => metadata.pax_size = Some(parse_decimal(value)? as u64),
403 key if key.starts_with("SCHILY.xattr.")
404 || key.starts_with("LIBARCHIVE.xattr.")
405 || key.starts_with("SCHILY.acl.")
406 || key.starts_with("GNU.sparse.") =>
407 {
408 metadata.diagnostics.push(MetadataDiagnostic {
409 profile: "pax-xattrs-acls",
410 message: "unsupported PAX metadata was ignored",
411 });
412 }
413 _ => metadata.diagnostics.push(MetadataDiagnostic {
414 profile: "pax-posix-2001",
415 message: "unsupported PAX key was ignored",
416 }),
417 }
418 cursor = record_end;
419 }
420 Ok(())
421}
422
423fn validate_symlink_target(link_path: &[u8], target: &[u8]) -> Result<(), FormatError> {
424 if target.is_empty()
425 || target.contains(&0)
426 || target.contains(&b'\\')
427 || target.contains(&b':')
428 || target[0] == b'/'
429 {
430 return Err(FormatError::UnsafeArchivePath);
431 }
432 let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
433 let link_path = std::str::from_utf8(link_path).map_err(|_| FormatError::UnsafeArchivePath)?;
434 let mut stack = link_path
435 .split('/')
436 .take(link_path.split('/').count().saturating_sub(1))
437 .map(str::to_owned)
438 .collect::<Vec<_>>();
439 for component in target.split('/') {
440 if component.is_empty() || component == "." {
441 return Err(FormatError::UnsafeArchivePath);
442 }
443 if component == ".." {
444 if stack.pop().is_none() {
445 return Err(FormatError::UnsafeArchivePath);
446 }
447 } else {
448 validate_file_path_bytes(component.as_bytes(), u32::MAX)?;
449 stack.push(component.to_owned());
450 }
451 }
452 Ok(())
453}
454
455fn prepare_destination(
456 root: &Path,
457 archive_path: &[u8],
458 kind: TarEntryKind,
459 options: SafeExtractionOptions,
460) -> Result<PathBuf, FormatError> {
461 let components = path_components(archive_path)?;
462 validate_root(root)?;
463 let mut current = root.to_path_buf();
464 for component in &components[..components.len().saturating_sub(1)] {
465 current.push(component);
466 match fs::symlink_metadata(¤t) {
467 Ok(metadata) => {
468 let file_type = metadata.file_type();
469 if file_type.is_symlink() || !file_type.is_dir() {
470 return Err(FormatError::UnsafeArchivePath);
471 }
472 }
473 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
474 fs::create_dir(¤t).map_err(|_| {
475 FormatError::FilesystemExtractionFailed("failed to create parent directory")
476 })?;
477 let metadata = fs::symlink_metadata(¤t).map_err(|_| {
478 FormatError::FilesystemExtractionFailed(
479 "failed to inspect created parent directory",
480 )
481 })?;
482 if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
483 return Err(FormatError::UnsafeArchivePath);
484 }
485 }
486 Err(_) => {
487 return Err(FormatError::FilesystemExtractionFailed(
488 "failed to inspect parent directory",
489 ));
490 }
491 }
492 }
493
494 current.push(components.last().ok_or(FormatError::UnsafeArchivePath)?);
495 match fs::symlink_metadata(¤t) {
496 Ok(metadata) => {
497 let file_type = metadata.file_type();
498 if file_type.is_symlink() {
499 return Err(FormatError::UnsafeArchivePath);
500 }
501 if kind == TarEntryKind::Directory {
502 if file_type.is_dir() {
503 return Ok(current);
504 }
505 return Err(FormatError::UnsafeOverwrite);
506 }
507 if file_type.is_dir() {
508 return Err(FormatError::UnsafeOverwrite);
509 }
510 if !options.overwrite_existing {
511 return Err(FormatError::UnsafeOverwrite);
512 }
513 }
514 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
515 Err(_) => {
516 return Err(FormatError::FilesystemExtractionFailed(
517 "failed to inspect destination",
518 ));
519 }
520 }
521 Ok(current)
522}
523
524fn validate_root(root: &Path) -> Result<(), FormatError> {
525 let metadata = fs::symlink_metadata(root).map_err(|_| {
526 FormatError::FilesystemExtractionFailed("extraction root must already exist")
527 })?;
528 if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
529 return Err(FormatError::UnsafeArchivePath);
530 }
531 Ok(())
532}
533
534fn existing_safe_regular_path(root: &Path, archive_path: &[u8]) -> Result<PathBuf, FormatError> {
535 validate_file_path_bytes(archive_path, u32::MAX)?;
536 let components = path_components(archive_path)?;
537 validate_root(root)?;
538 let mut current = root.to_path_buf();
539 for (idx, component) in components.iter().enumerate() {
540 current.push(component);
541 let metadata =
542 fs::symlink_metadata(¤t).map_err(|_| FormatError::UnsafeArchivePath)?;
543 if metadata.file_type().is_symlink() {
544 return Err(FormatError::UnsafeArchivePath);
545 }
546 if idx + 1 != components.len() {
547 if !metadata.file_type().is_dir() {
548 return Err(FormatError::UnsafeArchivePath);
549 }
550 } else if !metadata.file_type().is_file() {
551 return Err(FormatError::UnsafeArchivePath);
552 }
553 }
554 Ok(current)
555}
556
557fn write_regular_file(
558 destination: &Path,
559 data: &[u8],
560 options: SafeExtractionOptions,
561) -> Result<(), FormatError> {
562 if options.overwrite_existing && destination.exists() {
563 fs::remove_file(destination)
564 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
565 }
566 let mut file = OpenOptions::new()
567 .write(true)
568 .create_new(true)
569 .open(destination)
570 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create regular file"))?;
571 file.write_all(data)
572 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to write regular file"))
573}
574
575fn create_directory(destination: &Path) -> Result<(), FormatError> {
576 match fs::create_dir(destination) {
577 Ok(()) => Ok(()),
578 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
579 let metadata =
580 fs::symlink_metadata(destination).map_err(|_| FormatError::UnsafeOverwrite)?;
581 if metadata.file_type().is_dir() {
582 Ok(())
583 } else {
584 Err(FormatError::UnsafeOverwrite)
585 }
586 }
587 Err(_) => Err(FormatError::FilesystemExtractionFailed(
588 "failed to create directory",
589 )),
590 }
591}
592
593fn create_hardlink(
594 destination: &Path,
595 target: &Path,
596 options: SafeExtractionOptions,
597) -> Result<(), FormatError> {
598 if options.overwrite_existing && destination.exists() {
599 fs::remove_file(destination)
600 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
601 }
602 fs::hard_link(target, destination)
603 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create hardlink"))
604}
605
606#[cfg(unix)]
607fn create_symlink(
608 destination: &Path,
609 target: &[u8],
610 options: SafeExtractionOptions,
611) -> Result<(), FormatError> {
612 if options.overwrite_existing && destination.exists() {
613 fs::remove_file(destination)
614 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
615 }
616 let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
617 std::os::unix::fs::symlink(target, destination)
618 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create symlink"))
619}
620
621#[cfg(windows)]
622fn create_symlink(
623 destination: &Path,
624 target: &[u8],
625 options: SafeExtractionOptions,
626) -> Result<(), FormatError> {
627 if options.overwrite_existing && destination.exists() {
628 fs::remove_file(destination)
629 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to remove old file"))?;
630 }
631 let target = std::str::from_utf8(target).map_err(|_| FormatError::UnsafeArchivePath)?;
632 std::os::windows::fs::symlink_file(target, destination)
633 .map_err(|_| FormatError::FilesystemExtractionFailed("failed to create symlink"))
634}
635
636fn path_components(path: &[u8]) -> Result<Vec<String>, FormatError> {
637 validate_file_path_bytes(path, u32::MAX)?;
638 let path = std::str::from_utf8(path).map_err(|_| FormatError::UnsafeArchivePath)?;
639 Ok(path.split('/').map(str::to_owned).collect())
640}
641
642fn ustar_path(header: &[u8]) -> Vec<u8> {
643 let name = nul_trimmed(&header[0..100]);
644 let prefix = nul_trimmed(&header[345..500]);
645 if prefix.is_empty() {
646 name.to_vec()
647 } else {
648 let mut out = Vec::with_capacity(prefix.len() + 1 + name.len());
649 out.extend_from_slice(prefix);
650 out.push(b'/');
651 out.extend_from_slice(name);
652 out
653 }
654}
655
656fn trimmed_metadata_payload(payload: &[u8]) -> Vec<u8> {
657 let mut end = payload.len();
658 while end > 0 && payload[end - 1] == 0 {
659 end -= 1;
660 }
661 payload[..end].to_vec()
662}
663
664fn verify_tar_checksum(header: &[u8]) -> Result<(), FormatError> {
665 let stored = parse_tar_octal(&header[148..156])?;
666 let mut sum = 0u64;
667 for (idx, byte) in header.iter().enumerate() {
668 if (148..156).contains(&idx) {
669 sum += b' ' as u64;
670 } else {
671 sum += *byte as u64;
672 }
673 }
674 if stored != sum {
675 return Err(FormatError::InvalidArchive("tar header checksum mismatch"));
676 }
677 Ok(())
678}
679
680fn parse_tar_octal(field: &[u8]) -> Result<u64, FormatError> {
681 let mut value = 0u64;
682 let mut saw_digit = false;
683 for byte in field {
684 match *byte {
685 0 | b' ' if saw_digit => break,
686 0 | b' ' => {}
687 b'0'..=b'7' => {
688 saw_digit = true;
689 value = value
690 .checked_mul(8)
691 .and_then(|acc| acc.checked_add((*byte - b'0') as u64))
692 .ok_or(FormatError::InvalidArchive("tar octal field overflow"))?;
693 }
694 _ => return Err(FormatError::InvalidArchive("malformed tar octal field")),
695 }
696 }
697 Ok(value)
698}
699
700fn parse_decimal(field: &[u8]) -> Result<usize, FormatError> {
701 let mut value = 0usize;
702 if field.is_empty() {
703 return Err(FormatError::InvalidArchive("malformed decimal field"));
704 }
705 for byte in field {
706 if !byte.is_ascii_digit() {
707 return Err(FormatError::InvalidArchive("malformed decimal field"));
708 }
709 value = value
710 .checked_mul(10)
711 .and_then(|acc| acc.checked_add((byte - b'0') as usize))
712 .ok_or(FormatError::InvalidArchive("decimal field overflow"))?;
713 }
714 Ok(value)
715}
716
717fn nul_trimmed(bytes: &[u8]) -> &[u8] {
718 let end = bytes
719 .iter()
720 .position(|byte| *byte == 0)
721 .unwrap_or(bytes.len());
722 &bytes[..end]
723}
724
725fn padding_to_512(len: usize) -> usize {
726 let remainder = len % TAR_BLOCK_LEN;
727 if remainder == 0 {
728 0
729 } else {
730 TAR_BLOCK_LEN - remainder
731 }
732}
733
734fn slice(bytes: &[u8], offset: usize, len: usize) -> Result<&[u8], FormatError> {
735 let end = checked_add(offset, len)?;
736 bytes.get(offset..end).ok_or(FormatError::InvalidLength {
737 structure: "tar member",
738 expected: end,
739 actual: bytes.len(),
740 })
741}
742
743fn checked_add(lhs: usize, rhs: usize) -> Result<usize, FormatError> {
744 lhs.checked_add(rhs).ok_or(FormatError::InvalidArchive(
745 "tar member arithmetic overflow",
746 ))
747}
748
749fn to_usize(value: u64) -> Result<usize, FormatError> {
750 usize::try_from(value).map_err(|_| FormatError::InvalidArchive("tar member size overflow"))
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use tempfile::tempdir;
757
758 fn header(path: &[u8], kind: u8, size: usize, link: &[u8]) -> [u8; TAR_BLOCK_LEN] {
759 let mut header = [0u8; TAR_BLOCK_LEN];
760 header[..path.len()].copy_from_slice(path);
761 write_octal(&mut header[100..108], 0o644);
762 write_octal(&mut header[108..116], 0);
763 write_octal(&mut header[116..124], 0);
764 write_octal(&mut header[124..136], size as u64);
765 write_octal(&mut header[136..148], 0);
766 header[148..156].fill(b' ');
767 header[156] = kind;
768 header[157..157 + link.len()].copy_from_slice(link);
769 header[257..263].copy_from_slice(b"ustar\0");
770 header[263..265].copy_from_slice(b"00");
771 let checksum = header.iter().map(|byte| *byte as u64).sum::<u64>();
772 write_checksum(&mut header[148..156], checksum);
773 header
774 }
775
776 fn member(path: &[u8], kind: u8, data: &[u8], link: &[u8]) -> Vec<u8> {
777 let mut out = Vec::new();
778 out.extend_from_slice(&header(path, kind, data.len(), link));
779 out.extend_from_slice(data);
780 out.resize(out.len() + padding_to_512(data.len()), 0);
781 out
782 }
783
784 fn pax_record(key: &str, value: &[u8]) -> Vec<u8> {
785 let mut len = key.len() + value.len() + 4;
786 loop {
787 let candidate = len.to_string().len() + 1 + key.len() + 1 + value.len() + 1;
788 if candidate == len {
789 break;
790 }
791 len = candidate;
792 }
793 let mut out = Vec::new();
794 out.extend_from_slice(len.to_string().as_bytes());
795 out.push(b' ');
796 out.extend_from_slice(key.as_bytes());
797 out.push(b'=');
798 out.extend_from_slice(value);
799 out.push(b'\n');
800 out
801 }
802
803 fn write_octal(field: &mut [u8], value: u64) {
804 let digits = format!("{value:o}");
805 field.fill(0);
806 let start = field.len() - 1 - digits.len();
807 field[..start].fill(b'0');
808 field[start..start + digits.len()].copy_from_slice(digits.as_bytes());
809 }
810
811 fn write_checksum(field: &mut [u8], value: u64) {
812 let digits = format!("{value:06o}");
813 field[0..6].copy_from_slice(digits.as_bytes());
814 field[6] = 0;
815 field[7] = b' ';
816 }
817
818 #[test]
819 fn parses_ustar_regular_member() {
820 let bytes = member(b"dir/file.txt", b'0', b"hello", b"");
821 let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
822
823 assert_eq!(parsed.kind, TarEntryKind::Regular);
824 assert_eq!(parsed.path, b"dir/file.txt");
825 assert_eq!(parsed.data, b"hello");
826 assert_eq!(parsed.logical_size, 5);
827 }
828
829 #[test]
830 fn canonicalizes_one_directory_trailing_slash_only_for_directories() {
831 let dir = member(b"dir/", b'5', b"", b"");
832 assert_eq!(parse_tar_member_group(&dir, 4096).unwrap().path, b"dir");
833
834 let file = member(b"dir/", b'0', b"", b"");
835 assert_eq!(
836 parse_tar_member_group(&file, 4096).unwrap_err(),
837 FormatError::UnsafeArchivePath
838 );
839 }
840
841 #[test]
842 fn rejects_global_pax_headers() {
843 let bytes = member(b"pax", b'g', b"11 path=x\n", b"");
844 assert_eq!(
845 parse_tar_member_group(&bytes, 4096).unwrap_err(),
846 FormatError::InvalidArchive("global PAX headers are not allowed")
847 );
848 }
849
850 #[test]
851 fn applies_local_pax_path_and_size() {
852 let pax = pax_record("path", b"long/name.txt");
853 let mut bytes = member(b"PaxHeaders/name", b'x', &pax, b"");
854 bytes.extend_from_slice(&member(b"short", b'0', b"abc", b""));
855
856 let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
857 assert_eq!(parsed.path, b"long/name.txt");
858 assert_eq!(parsed.data, b"abc");
859 }
860
861 #[test]
862 fn rejects_platform_escape_paths() {
863 for path in [
864 b"/abs".as_slice(),
865 b"../up".as_slice(),
866 b"a//b".as_slice(),
867 b"a\\b".as_slice(),
868 b"a:b".as_slice(),
869 b"CON".as_slice(),
870 ] {
871 let bytes = member(path, b'0', b"", b"");
872 assert_eq!(
873 parse_tar_member_group(&bytes, 4096).unwrap_err(),
874 FormatError::UnsafeArchivePath
875 );
876 }
877 }
878
879 #[test]
880 fn safe_restore_rejects_symlink_parent() {
881 let tmp = tempdir().unwrap();
882 let outside = tempdir().unwrap();
883 #[cfg(unix)]
884 std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap();
885
886 #[cfg(unix)]
887 {
888 let member = OwnedTarMember {
889 path: b"link/file.txt".to_vec(),
890 kind: TarEntryKind::Regular,
891 data: b"blocked".to_vec(),
892 link_target: None,
893 mode: 0o644,
894 mtime: 0,
895 logical_size: 7,
896 diagnostics: Vec::new(),
897 };
898
899 assert_eq!(
900 restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default())
901 .unwrap_err(),
902 FormatError::UnsafeArchivePath
903 );
904 }
905 }
906
907 #[test]
908 fn safe_restore_requires_hardlink_target_to_be_existing_regular_file() {
909 let tmp = tempdir().unwrap();
910 fs::write(tmp.path().join("target.txt"), b"target").unwrap();
911 let member = OwnedTarMember {
912 path: b"linked.txt".to_vec(),
913 kind: TarEntryKind::Hardlink,
914 data: Vec::new(),
915 link_target: Some(b"target.txt".to_vec()),
916 mode: 0o644,
917 mtime: 0,
918 logical_size: 0,
919 diagnostics: Vec::new(),
920 };
921
922 restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
923 assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
924 }
925
926 #[test]
927 fn restore_revalidates_symlink_targets_from_owned_members() {
928 let tmp = tempdir().unwrap();
929 let member = OwnedTarMember {
930 path: b"link".to_vec(),
931 kind: TarEntryKind::Symlink,
932 data: Vec::new(),
933 link_target: Some(b"/outside".to_vec()),
934 mode: 0o644,
935 mtime: 0,
936 logical_size: 0,
937 diagnostics: Vec::new(),
938 };
939
940 assert_eq!(
941 restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap_err(),
942 FormatError::UnsafeArchivePath
943 );
944 assert!(!tmp.path().join("link").exists());
945 }
946
947 #[test]
948 fn safe_restore_rejects_directory_over_existing_file_even_with_overwrite() {
949 let tmp = tempdir().unwrap();
950 let conflict = tmp.path().join("conflict");
951 fs::write(&conflict, b"not a directory").unwrap();
952 let member = OwnedTarMember {
953 path: b"conflict".to_vec(),
954 kind: TarEntryKind::Directory,
955 data: Vec::new(),
956 link_target: None,
957 mode: 0o644,
958 mtime: 0,
959 logical_size: 0,
960 diagnostics: Vec::new(),
961 };
962
963 assert_eq!(
964 restore_tar_member(
965 tmp.path(),
966 &member,
967 SafeExtractionOptions {
968 overwrite_existing: true
969 }
970 )
971 .unwrap_err(),
972 FormatError::UnsafeOverwrite
973 );
974 assert!(conflict.is_file());
975 }
976
977 #[test]
978 fn hardlink_target_checks_use_component_position_not_value() {
979 let tmp = tempdir().unwrap();
980 fs::create_dir(tmp.path().join("a")).unwrap();
981 fs::write(tmp.path().join("a").join("a"), b"target").unwrap();
982 let member = OwnedTarMember {
983 path: b"linked.txt".to_vec(),
984 kind: TarEntryKind::Hardlink,
985 data: Vec::new(),
986 link_target: Some(b"a/a".to_vec()),
987 mode: 0o644,
988 mtime: 0,
989 logical_size: 0,
990 diagnostics: Vec::new(),
991 };
992
993 restore_tar_member(tmp.path(), &member, SafeExtractionOptions::default()).unwrap();
994 assert_eq!(fs::read(tmp.path().join("linked.txt")).unwrap(), b"target");
995 }
996
997 #[test]
998 fn hardlink_targets_obey_max_path_length() {
999 let bytes = member(b"link", b'1', b"", b"long/name");
1000
1001 assert_eq!(
1002 parse_tar_member_group(&bytes, 4).unwrap_err(),
1003 FormatError::UnsafeArchivePath
1004 );
1005 }
1006}