Skip to main content

tar_codec/
decode.rs

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