Skip to main content

tar_codec/
decode.rs

1//! Member-oriented decoding of pax or GNU tar streams.
2//!
3//! The decoder applies format extensions to member paths and link targets.
4//! It does not normalize these values for extraction. Do not use them directly
5//! as file-system paths. [`ArchiveTrait::extract_in`] validates and normalizes
6//! them as specified by [`crate::extract::ExtractPolicy`].
7
8use std::collections::HashSet;
9
10use archive_trait::{
11    Archive as ArchiveTrait, Member, MemberMetadata, MemberPayload as MemberPayloadTrait,
12    SpecialKind,
13};
14use tar_framing::{
15    ArchiveFormat, FrameError, PaxKeyword, PaxKind, PaxRecord, PaxValue, StreamPolicy, UstarKind,
16    logical::{MemberExtensions, MemberFrame, MemberPayload as FramingMemberPayload, TarReader},
17};
18use thiserror::Error;
19use tokio::io::AsyncRead;
20
21pub use tar_framing::{
22    DEFAULT_MAX_GLOBAL_PAX_EXTENSIONS_SIZE, DEFAULT_MAX_GNU_EXTENSION_SIZE,
23    DEFAULT_MAX_PAX_EXTENSION_SIZE,
24};
25
26/// This type reads one structurally valid pax or GNU tar archive in one pass.
27///
28/// Member paths and link targets are not normalized for extraction. A direct
29/// consumer must validate them before it uses them as file-system paths.
30/// [`ArchiveTrait::extract_in`] does this validation.
31///
32/// Member iteration is fused. After reaching the end of the archive or
33/// returning a decoding error, every subsequent attempt returns end-of-archive.
34pub struct TarArchive<R> {
35    reader: TarReader<R>,
36    policy: DecodePolicy,
37    fused: bool,
38}
39
40impl<R> TarArchive<R> {
41    /// Creates an archive decoder from an uncompressed tar reader.
42    pub fn new(reader: R) -> Self {
43        Self {
44            reader: TarReader::new(reader),
45            policy: DecodePolicy::default(),
46            fused: false,
47        }
48    }
49
50    /// Configures the decoding policy used by this archive.
51    ///
52    /// Call before reading any members.
53    pub fn with_policy(mut self, policy: DecodePolicy) -> Self {
54        let stream_policy = StreamPolicy::default()
55            .max_pax_extension_size(policy.pax_policy.max_extension_size)
56            .max_global_pax_extensions_size(policy.pax_policy.max_global_extensions_size)
57            .allow_all_nul_numeric_fields(policy.allow_all_nul_numeric_fields)
58            .allow_header_uname_and_gname_without_terminator(
59                policy.allow_header_uname_and_gname_without_terminator,
60            )
61            .max_gnu_extension_size(policy.max_gnu_extension_size);
62        self.reader = self.reader.with_policy(stream_policy);
63        self.policy = policy;
64        self
65    }
66}
67
68/// Controls tar compatibility and the feature subset member decoding may accept.
69///
70/// See each configuration API for its default.
71#[derive(Clone, Debug)]
72pub struct DecodePolicy {
73    allow_gnu: bool,
74    allow_all_nul_numeric_fields: bool,
75    allow_header_uname_and_gname_without_terminator: bool,
76    max_gnu_extension_size: u64,
77    pax_policy: PaxDecodePolicy,
78}
79
80/// Controls pax compatibility and the feature subset member decoding may accept.
81///
82/// See each allow API for its default.
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct PaxDecodePolicy {
85    max_extension_size: u64,
86    max_global_extensions_size: u64,
87    allow_non_utf8_pax_vendor_values: bool,
88    allow_global_pax_extensions: bool,
89    vendor_extension_policy: PaxVendorExtensionPolicy,
90    allow_duplicate_pax_records: bool,
91    allow_global_pax_member_metadata: bool,
92}
93
94/// Controls which vendor-namespaced pax records may be ignored during decoding.
95#[derive(Clone, Debug, Default, Eq, PartialEq)]
96pub enum PaxVendorExtensionPolicy {
97    /// Reject every unknown vendor-namespaced pax record.
98    #[default]
99    RejectUnknown,
100    /// Ignore only records whose vendor namespaces appear in this allowlist.
101    ///
102    /// A vendor namespace such as `SCHILY` permits every `SCHILY.*` record.
103    Ignore(PaxVendorAllowlist),
104    /// Ignore every unknown vendor-namespaced pax record.
105    ///
106    /// Unknown vendor semantics can affect the archive's intended contents.
107    AllowUnknown,
108}
109
110impl PaxVendorExtensionPolicy {
111    /// Ignores vendor records whose vendor namespaces appear in `vendors`.
112    ///
113    /// A vendor namespace such as `SCHILY` permits every `SCHILY.*` record.
114    pub fn ignore(vendors: impl IntoIterator<Item = &'static str>) -> Self {
115        Self::Ignore(PaxVendorAllowlist {
116            vendors: vendors.into_iter().collect(),
117        })
118    }
119}
120
121/// An opaque allowlist of pax vendor namespaces.
122///
123/// Construct an allowlist with [`PaxVendorExtensionPolicy::ignore`].
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct PaxVendorAllowlist {
126    vendors: HashSet<&'static str>,
127}
128
129impl Default for PaxDecodePolicy {
130    fn default() -> Self {
131        Self {
132            max_extension_size: DEFAULT_MAX_PAX_EXTENSION_SIZE,
133            max_global_extensions_size: DEFAULT_MAX_GLOBAL_PAX_EXTENSIONS_SIZE,
134            allow_non_utf8_pax_vendor_values: true,
135            allow_global_pax_extensions: true,
136            vendor_extension_policy: PaxVendorExtensionPolicy::default(),
137            allow_duplicate_pax_records: false,
138            allow_global_pax_member_metadata: false,
139        }
140    }
141}
142
143impl Default for DecodePolicy {
144    fn default() -> Self {
145        Self {
146            allow_gnu: true,
147            allow_all_nul_numeric_fields: true,
148            allow_header_uname_and_gname_without_terminator: false,
149            max_gnu_extension_size: DEFAULT_MAX_GNU_EXTENSION_SIZE,
150            pax_policy: PaxDecodePolicy::default(),
151        }
152    }
153}
154
155impl DecodePolicy {
156    /// Configures whether archives in the GNU framing family may be decoded.
157    ///
158    /// GNU tar archives are **allowed by default**.
159    ///
160    /// Users who wish to parse strictly pax-confirming tar archives may wish to
161    /// disable this setting.
162    pub fn allow_gnu(mut self, allow: bool) -> Self {
163        self.allow_gnu = allow;
164        self
165    }
166
167    /// Configures whether wholly NUL numeric metadata fields may be accepted.
168    ///
169    /// This compatibility option applies to the ordinary header's `mode`, `uid`,
170    /// `gid`, and `mtime` fields in both pax/ustar and GNU archives. It is
171    /// **enabled by default**. When enabled, a wholly NUL field is represented
172    /// as missing; every other value must be a valid numeric encoding for its
173    /// archive family.
174    pub fn allow_all_nul_numeric_fields(mut self, allow: bool) -> Self {
175        self.allow_all_nul_numeric_fields = allow;
176        self
177    }
178
179    /// Configures whether ordinary header `uname` and `gname` fields may omit their NUL terminators.
180    ///
181    /// This compatibility option applies to ordinary pax/ustar and GNU member
182    /// headers and is **disabled by default**. Enabling it accepts names that
183    /// occupy all 32 bytes of either field.
184    pub fn allow_header_uname_and_gname_without_terminator(mut self, allow: bool) -> Self {
185        self.allow_header_uname_and_gname_without_terminator = allow;
186        self
187    }
188
189    /// Configures the maximum payload size accepted for one GNU metadata extension.
190    ///
191    /// The limit applies independently to long-name and long-link extensions.
192    /// An extension that declares a larger payload is rejected before its
193    /// payload is consumed. The default is [`DEFAULT_MAX_GNU_EXTENSION_SIZE`].
194    /// Setting the limit to zero rejects every nonempty GNU extension. Setting
195    /// it to [`u64::MAX`] permits unbounded metadata buffering.
196    pub fn max_gnu_extension_size(mut self, max_gnu_extension_size: u64) -> Self {
197        self.max_gnu_extension_size = max_gnu_extension_size;
198        self
199    }
200
201    /// Configures the accepted pax feature subset.
202    pub fn pax_policy(mut self, policy: PaxDecodePolicy) -> Self {
203        self.pax_policy = policy;
204        self
205    }
206
207    fn check_format(&self, position: u64, format: ArchiveFormat) -> Result<(), DecodeError> {
208        if format == ArchiveFormat::Gnu && !self.allow_gnu {
209            return Err(DecodeError::policy_violation(
210                position,
211                DecodePolicyViolation::GnuArchive,
212            ));
213        }
214        Ok(())
215    }
216
217    fn check_global_pax(&self, position: u64, records: &[PaxRecord]) -> Result<(), DecodeError> {
218        self.pax_policy.check_global_pax_extension(position)?;
219        self.pax_policy
220            .check_pax_records(position, PaxKind::Global, records)
221    }
222
223    fn check_member<R>(&self, frame: &MemberFrame<'_, R>) -> Result<(), DecodeError> {
224        if let MemberExtensions::Pax(state) = &frame.extensions {
225            for extension in state
226                .extensions()
227                .filter(|extension| extension.kind == PaxKind::Global)
228            {
229                self.check_global_pax(extension.position, extension.records())?;
230            }
231        }
232        let format_position = match &frame.extensions {
233            MemberExtensions::Pax(_) => frame.header.position,
234            MemberExtensions::Gnu {
235                long_name,
236                long_link,
237            } => long_name
238                .iter()
239                .chain(long_link.iter())
240                .map(|header| header.position)
241                .min()
242                .unwrap_or(frame.header.position),
243        };
244        self.check_format(format_position, frame.header.format)?;
245        if let MemberExtensions::Pax(state) = &frame.extensions {
246            for extension in state
247                .extensions()
248                .filter(|extension| extension.kind == PaxKind::Local)
249            {
250                self.pax_policy.check_pax_records(
251                    extension.position,
252                    PaxKind::Local,
253                    extension.records(),
254                )?;
255            }
256        }
257        Ok(())
258    }
259}
260
261impl PaxDecodePolicy {
262    /// Configures the maximum payload size in bytes accepted for one pax extension.
263    ///
264    /// The limit applies independently to each local or global extension and
265    /// covers all records in that extension. An extension that declares a
266    /// larger payload is rejected before its payload is consumed.
267    ///
268    /// The default is [`DEFAULT_MAX_PAX_EXTENSION_SIZE`]. Setting the limit to
269    /// zero rejects every nonempty pax extension. Setting it to [`u64::MAX`]
270    /// removes the limit for each extension. Each global extension still counts
271    /// toward the limit for the full archive.
272    pub fn max_extension_size(mut self, max_extension_size: u64) -> Self {
273        self.max_extension_size = max_extension_size;
274        self
275    }
276
277    /// Sets the maximum total payload size of all global pax extensions.
278    ///
279    /// This limit applies to the full archive. It does not reset after an
280    /// ordinary member.
281    ///
282    /// The default is [`DEFAULT_MAX_GLOBAL_PAX_EXTENSIONS_SIZE`]. A value of zero
283    /// rejects each nonempty global extension. A value of [`u64::MAX`] removes
284    /// this limit. The separate limit for each extension still applies.
285    pub fn max_global_extensions_size(mut self, max_global_extensions_size: u64) -> Self {
286        self.max_global_extensions_size = max_global_extensions_size;
287        self
288    }
289
290    /// Configures whether vendor-namespaced pax record values may contain non-UTF-8 bytes.
291    ///
292    /// This compatibility option is enabled by default to accommodate raw extensions
293    /// incorrectly emitted by other real-world writers. Disabling it requires every vendor
294    /// record value to be valid UTF-8. Vendor values remain exposed as opaque
295    /// bytes in either mode.
296    ///
297    /// [`Self::vendor_extension_policy`] separately controls whether decoding
298    /// may ignore vendor records after they have been parsed.
299    pub fn allow_non_utf8_pax_vendor_values(mut self, allow: bool) -> Self {
300        self.allow_non_utf8_pax_vendor_values = allow;
301        self
302    }
303
304    /// Configures whether global pax extension headers may be accepted.
305    ///
306    /// When enabled, [`Self::allow_global_pax_member_metadata`] separately
307    /// controls whether global `path`, `linkpath`, and `size` records are
308    /// accepted. Trailing global headers without a following ordinary member
309    /// are consumed and ignored before policy checks.
310    ///
311    /// Global pax extension headers are **allowed by default**.
312    pub fn allow_global_pax_extensions(mut self, allow: bool) -> Self {
313        self.allow_global_pax_extensions = allow;
314        self
315    }
316
317    /// Configures which unknown vendor-namespaced pax records may be ignored.
318    ///
319    /// [`PaxVendorExtensionPolicy::Ignore`] accepts every record under its
320    /// explicitly listed vendor namespaces, while
321    /// [`PaxVendorExtensionPolicy::AllowUnknown`] accepts every
322    /// vendor-namespaced record. Accepted values are parsed structurally, but
323    /// their semantics are not interpreted.
324    ///
325    /// This can produce output that differs from the archive's intended
326    /// contents. For example, `GNU.sparse.*` records can change a member's
327    /// effective name, logical size, and mapping from stored payload bytes to
328    /// file contents; these semantics are ignored when this option is enabled.
329    ///
330    /// **IMPORTANT**: Only permit records whose ignored semantics are
331    /// acceptable. Unknown vendor-namespaced pax records are **forbidden by
332    /// default**.
333    pub fn vendor_extension_policy(mut self, policy: PaxVendorExtensionPolicy) -> Self {
334        self.vendor_extension_policy = policy;
335        self
336    }
337
338    /// Configures whether one pax extended header may repeat a keyword.
339    ///
340    /// When enabled, standard pax precedence applies and the last record for
341    /// a repeated keyword takes effect.
342    ///
343    /// Duplicated pax records within a single header are **forbidden by default**.
344    pub fn allow_duplicate_pax_records(mut self, allow: bool) -> Self {
345        self.allow_duplicate_pax_records = allow;
346        self
347    }
348
349    /// Configures whether global pax headers may set member path or size data.
350    ///
351    /// When enabled, standard pax semantics permit global `path`, `linkpath`,
352    /// and `size` records to apply to following members until overridden.
353    ///
354    /// Member metadata within global pax headers is **forbidden by default**,
355    /// as it is extremely differential-prone.
356    pub fn allow_global_pax_member_metadata(mut self, allow: bool) -> Self {
357        self.allow_global_pax_member_metadata = allow;
358        self
359    }
360
361    fn check_global_pax_extension(&self, position: u64) -> Result<(), DecodeError> {
362        if !self.allow_global_pax_extensions {
363            return Err(DecodeError::policy_violation(
364                position,
365                DecodePolicyViolation::GlobalPaxExtension,
366            ));
367        }
368        Ok(())
369    }
370
371    fn check_pax_records(
372        &self,
373        position: u64,
374        kind: PaxKind,
375        records: &[PaxRecord],
376    ) -> Result<(), DecodeError> {
377        for record in records {
378            if let PaxRecord::Vendor {
379                vendor,
380                name,
381                value,
382            } = record
383            {
384                let allowed = match &self.vendor_extension_policy {
385                    PaxVendorExtensionPolicy::RejectUnknown => false,
386                    PaxVendorExtensionPolicy::Ignore(allowed) => {
387                        allowed.vendors.contains(vendor.as_ref())
388                    }
389                    PaxVendorExtensionPolicy::AllowUnknown => true,
390                };
391                if !allowed {
392                    return Err(DecodeError::policy_violation(
393                        position,
394                        DecodePolicyViolation::PaxVendorExtension {
395                            vendor: vendor.to_string(),
396                            name: name.to_string(),
397                        },
398                    ));
399                }
400
401                if !self.allow_non_utf8_pax_vendor_values
402                    && let PaxValue::Value(value) = value
403                    && std::str::from_utf8(value).is_err()
404                {
405                    return Err(DecodeError::policy_violation(
406                        position,
407                        DecodePolicyViolation::NonUtf8PaxVendorValue {
408                            vendor: vendor.to_string(),
409                            name: name.to_string(),
410                        },
411                    ));
412                }
413            }
414        }
415
416        if kind == PaxKind::Global && !self.allow_global_pax_member_metadata {
417            for record in records {
418                let keyword = match record.keyword() {
419                    PaxKeyword::Path => Some("path"),
420                    PaxKeyword::LinkPath => Some("linkpath"),
421                    PaxKeyword::Size => Some("size"),
422                    _ => None,
423                };
424                if let Some(keyword) = keyword {
425                    return Err(DecodeError::policy_violation(
426                        position,
427                        DecodePolicyViolation::GlobalPaxMemberMetadata { keyword },
428                    ));
429                }
430            }
431        }
432
433        if !self.allow_duplicate_pax_records {
434            let mut keywords = HashSet::new();
435            for record in records {
436                let keyword = record.keyword();
437                if !keywords.insert(keyword.clone()) {
438                    return Err(DecodeError::policy_violation(
439                        position,
440                        DecodePolicyViolation::DuplicatePaxRecord {
441                            keyword: keyword.to_string(),
442                        },
443                    ));
444                }
445            }
446        }
447
448        Ok(())
449    }
450}
451
452/// A tar feature accepted by framing but rejected by the selected [`DecodePolicy`].
453#[derive(Clone, Debug, Eq, PartialEq, Error)]
454pub enum DecodePolicyViolation {
455    /// A GNU-family frame appeared when only POSIX-pax decoding is allowed.
456    #[error("GNU archives are not allowed")]
457    GnuArchive,
458    /// A global POSIX pax extended header appeared when it is forbidden.
459    #[error("global pax extended headers are not allowed")]
460    GlobalPaxExtension,
461    /// A vendor-namespaced POSIX pax record appeared.
462    #[error("pax vendor extension {vendor}.{name} is not allowed")]
463    PaxVendorExtension {
464        /// Vendor namespace.
465        vendor: String,
466        /// Keyword suffix following the vendor namespace.
467        name: String,
468    },
469    /// A vendor-namespaced POSIX pax record contains a non-UTF-8 value.
470    #[error("pax vendor extension {vendor}.{name} contains a non-UTF-8 value")]
471    NonUtf8PaxVendorValue {
472        /// Vendor namespace.
473        vendor: String,
474        /// Keyword suffix following the vendor namespace.
475        name: String,
476    },
477    /// One POSIX pax extended header repeats the same logical keyword.
478    #[error("pax extended header contains duplicate record {keyword}")]
479    DuplicatePaxRecord {
480        /// The repeated POSIX pax record keyword.
481        keyword: String,
482    },
483    /// A global POSIX pax header supplies per-member identity or framing data.
484    #[error("global pax extended header contains restricted member metadata {keyword}")]
485    GlobalPaxMemberMetadata {
486        /// The restricted global record keyword.
487        keyword: &'static str,
488    },
489}
490
491/// An error produced while decoding tar members.
492#[derive(Debug, Error)]
493pub enum DecodeError {
494    /// The underlying tar stream is not structurally valid.
495    #[error(transparent)]
496    Framing(#[from] FrameError),
497    /// An effective member path or link target is not UTF-8 text.
498    #[error("at byte {position}: {field} is not valid UTF-8")]
499    InvalidUtf8 {
500        /// Source tar block position.
501        position: u64,
502        /// Metadata field being decoded.
503        field: &'static str,
504    },
505    /// A structurally valid tar feature was rejected by decode policy.
506    #[error("at byte {position}: decode policy rejected input: {violation}")]
507    PolicyViolation {
508        /// Source header position for the rejected feature.
509        position: u64,
510        /// The selected policy rule that rejected the feature.
511        violation: DecodePolicyViolation,
512    },
513}
514
515impl DecodeError {
516    fn policy_violation(position: u64, violation: DecodePolicyViolation) -> Self {
517        Self::PolicyViolation {
518            position,
519            violation,
520        }
521    }
522}
523
524/// A tar member payload adapted to [`MemberPayloadTrait`].
525pub struct TarMemberPayload<'a, R> {
526    payload: FramingMemberPayload<'a, R>,
527}
528
529impl<R: AsyncRead + Unpin> MemberPayloadTrait for TarMemberPayload<'_, R> {
530    type Error = DecodeError;
531
532    async fn next_chunk(
533        &mut self,
534        buffer: &mut Vec<u8>,
535        target_len: usize,
536    ) -> Result<bool, Self::Error> {
537        self.payload
538            .next_chunk(buffer, target_len)
539            .await
540            .map_err(Into::into)
541    }
542
543    async fn skip(self) -> Result<(), Self::Error> {
544        self.payload.skip().await.map_err(Into::into)
545    }
546}
547
548impl<R: AsyncRead + Unpin> ArchiveTrait for TarArchive<R> {
549    type Error = DecodeError;
550    type Payload<'a>
551        = TarMemberPayload<'a, R>
552    where
553        Self: 'a;
554
555    async fn next_member<'a>(
556        &'a mut self,
557    ) -> Result<Option<Member<Self::Payload<'a>>>, Self::Error> {
558        if self.fused {
559            return Ok(None);
560        }
561
562        let frame = match self.reader.next_frame().await {
563            Ok(Some(frame)) => frame,
564            Ok(None) => {
565                self.fused = true;
566                return Ok(None);
567            }
568            Err(error) => {
569                self.fused = true;
570                return Err(error.into());
571            }
572        };
573
574        if let Err(error) = self.policy.check_member(&frame) {
575            self.fused = true;
576            return Err(error);
577        }
578
579        match project_member(frame) {
580            Ok(member) => Ok(Some(member)),
581            Err(error) => {
582                self.fused = true;
583                Err(error)
584            }
585        }
586    }
587}
588
589fn project_member<'a, R>(
590    frame: MemberFrame<'a, R>,
591) -> Result<Member<TarMemberPayload<'a, R>>, DecodeError> {
592    let position = frame.header.position;
593    let kind = frame.header.kind;
594    let size = frame.header.effective_size;
595    let executable = frame.header.mode.unwrap_or_default() & 0o111 != 0;
596    let path = std::str::from_utf8(frame.effective_path()?.as_ref())
597        .map(str::to_owned)
598        .map_err(|_| DecodeError::InvalidUtf8 {
599            position,
600            field: "path",
601        })?;
602    let target = if matches!(kind, UstarKind::HardLink | UstarKind::SymbolicLink) {
603        std::str::from_utf8(frame.effective_link_path()?.as_ref())
604            .map(str::to_owned)
605            .map_err(|_| DecodeError::InvalidUtf8 {
606                position,
607                field: "linkpath",
608            })?
609    } else {
610        String::new()
611    };
612    let metadata = MemberMetadata { path, position };
613
614    Ok(match kind {
615        UstarKind::Regular | UstarKind::Contiguous => Member::File {
616            metadata,
617            size,
618            executable,
619            payload: TarMemberPayload {
620                payload: frame.payload,
621            },
622        },
623        UstarKind::Directory => Member::Directory { metadata },
624        UstarKind::SymbolicLink => Member::SymbolicLink { metadata, target },
625        UstarKind::HardLink => Member::HardLink {
626            metadata,
627            target,
628            size,
629            payload: TarMemberPayload {
630                payload: frame.payload,
631            },
632        },
633        UstarKind::CharacterDevice => Member::Special {
634            metadata,
635            kind: SpecialKind::CharacterDevice,
636        },
637        UstarKind::BlockDevice => Member::Special {
638            metadata,
639            kind: SpecialKind::BlockDevice,
640        },
641        UstarKind::Fifo => Member::Special {
642            metadata,
643            kind: SpecialKind::Fifo,
644        },
645    })
646}