Skip to main content

tzap_core/
tar_model.rs

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(&current) {
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(&current).map_err(|_| {
475                    FormatError::FilesystemExtractionFailed("failed to create parent directory")
476                })?;
477                let metadata = fs::symlink_metadata(&current).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(&current) {
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(&current).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}