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