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 complete keywords appear in this allowlist.
88    ///
89    /// Keywords include the vendor namespace, such as `Acme.attribute`.
90    Ignore(PaxVendorExtensionAllowlist),
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 complete keywords appear in `keywords`.
99    ///
100    /// Keywords include the vendor namespace, such as `Acme.attribute`.
101    pub fn ignore(keywords: impl IntoIterator<Item = &'static str>) -> Self {
102        Self::Ignore(PaxVendorExtensionAllowlist {
103            keywords: keywords.into_iter().collect(),
104        })
105    }
106}
107
108/// An opaque allowlist of vendor-namespaced pax record keywords.
109///
110/// Construct an allowlist with [`PaxVendorExtensionPolicy::ignore`].
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct PaxVendorExtensionAllowlist {
113    keywords: 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 only explicitly listed
297    /// complete keywords, while [`PaxVendorExtensionPolicy::AllowUnknown`]
298    /// accepts every vendor-namespaced record. Accepted values are parsed
299    /// structurally, but their semantics are not interpreted.
300    ///
301    /// This can produce output that differs from the archive's intended
302    /// contents. For example, `GNU.sparse.*` records can change a member's
303    /// effective name, logical size, and mapping from stored payload bytes to
304    /// file contents; these semantics are ignored when this option is enabled.
305    ///
306    /// **IMPORTANT**: Only permit records whose ignored semantics are
307    /// acceptable. Unknown vendor-namespaced pax records are **forbidden by
308    /// default**.
309    pub fn vendor_extension_policy(mut self, policy: PaxVendorExtensionPolicy) -> Self {
310        self.vendor_extension_policy = policy;
311        self
312    }
313
314    /// Configures whether one pax extended header may repeat a keyword.
315    ///
316    /// When enabled, standard pax precedence applies and the last record for
317    /// a repeated keyword takes effect.
318    ///
319    /// Duplicated pax records within a single header are **forbidden by default**.
320    pub fn allow_duplicate_pax_records(mut self, allow: bool) -> Self {
321        self.allow_duplicate_pax_records = allow;
322        self
323    }
324
325    /// Configures whether global pax headers may set member path or size data.
326    ///
327    /// When enabled, standard pax semantics permit global `path`, `linkpath`,
328    /// and `size` records to apply to following members until overridden.
329    ///
330    /// Member metadata within global pax headers is **forbidden by default**,
331    /// as it is extremely differential-prone.
332    pub fn allow_global_pax_member_metadata(mut self, allow: bool) -> Self {
333        self.allow_global_pax_member_metadata = allow;
334        self
335    }
336
337    fn check_global_pax_extension(&self, position: u64) -> Result<(), DecodeError> {
338        if !self.allow_global_pax_extensions {
339            return Err(DecodeError::policy_violation(
340                position,
341                DecodePolicyViolation::GlobalPaxExtension,
342            ));
343        }
344        Ok(())
345    }
346
347    fn check_pax_records(
348        &self,
349        position: u64,
350        kind: PaxKind,
351        records: &[PaxRecord],
352    ) -> Result<(), DecodeError> {
353        for record in records {
354            if let PaxRecord::Vendor {
355                vendor,
356                name,
357                value,
358            } = record
359            {
360                let allowed = match &self.vendor_extension_policy {
361                    PaxVendorExtensionPolicy::RejectUnknown => false,
362                    PaxVendorExtensionPolicy::Ignore(allowed) => allowed
363                        .keywords
364                        .contains(format!("{vendor}.{name}").as_str()),
365                    PaxVendorExtensionPolicy::AllowUnknown => true,
366                };
367                if !allowed {
368                    return Err(DecodeError::policy_violation(
369                        position,
370                        DecodePolicyViolation::PaxVendorExtension {
371                            vendor: vendor.to_string(),
372                            name: name.to_string(),
373                        },
374                    ));
375                }
376
377                if !self.allow_non_utf8_pax_vendor_values
378                    && let PaxValue::Value(value) = value
379                    && std::str::from_utf8(value).is_err()
380                {
381                    return Err(DecodeError::policy_violation(
382                        position,
383                        DecodePolicyViolation::NonUtf8PaxVendorValue {
384                            vendor: vendor.to_string(),
385                            name: name.to_string(),
386                        },
387                    ));
388                }
389            }
390        }
391
392        if kind == PaxKind::Global && !self.allow_global_pax_member_metadata {
393            for record in records {
394                let keyword = match record.keyword() {
395                    PaxKeyword::Path => Some("path"),
396                    PaxKeyword::LinkPath => Some("linkpath"),
397                    PaxKeyword::Size => Some("size"),
398                    _ => None,
399                };
400                if let Some(keyword) = keyword {
401                    return Err(DecodeError::policy_violation(
402                        position,
403                        DecodePolicyViolation::GlobalPaxMemberMetadata { keyword },
404                    ));
405                }
406            }
407        }
408
409        if !self.allow_duplicate_pax_records {
410            let mut keywords = HashSet::new();
411            for record in records {
412                let keyword = record.keyword();
413                if !keywords.insert(keyword.clone()) {
414                    return Err(DecodeError::policy_violation(
415                        position,
416                        DecodePolicyViolation::DuplicatePaxRecord {
417                            keyword: keyword.to_string(),
418                        },
419                    ));
420                }
421            }
422        }
423
424        Ok(())
425    }
426}
427
428/// A tar feature accepted by framing but rejected by the selected [`DecodePolicy`].
429#[derive(Clone, Debug, Eq, PartialEq, Error)]
430pub enum DecodePolicyViolation {
431    /// A GNU-family frame appeared when only POSIX-pax decoding is allowed.
432    #[error("GNU archives are not allowed")]
433    GnuArchive,
434    /// A global POSIX pax extended header appeared when it is forbidden.
435    #[error("global pax extended headers are not allowed")]
436    GlobalPaxExtension,
437    /// A vendor-namespaced POSIX pax record appeared.
438    #[error("pax vendor extension {vendor}.{name} is not allowed")]
439    PaxVendorExtension {
440        /// Vendor namespace.
441        vendor: String,
442        /// Keyword suffix following the vendor namespace.
443        name: String,
444    },
445    /// A vendor-namespaced POSIX pax record contains a non-UTF-8 value.
446    #[error("pax vendor extension {vendor}.{name} contains a non-UTF-8 value")]
447    NonUtf8PaxVendorValue {
448        /// Vendor namespace.
449        vendor: String,
450        /// Keyword suffix following the vendor namespace.
451        name: String,
452    },
453    /// One POSIX pax extended header repeats the same logical keyword.
454    #[error("pax extended header contains duplicate record {keyword}")]
455    DuplicatePaxRecord {
456        /// The repeated POSIX pax record keyword.
457        keyword: String,
458    },
459    /// A global POSIX pax header supplies per-member identity or framing data.
460    #[error("global pax extended header contains restricted member metadata {keyword}")]
461    GlobalPaxMemberMetadata {
462        /// The restricted global record keyword.
463        keyword: &'static str,
464    },
465}
466
467/// An error produced while decoding tar members.
468#[derive(Debug, Error)]
469pub enum DecodeError {
470    /// The underlying tar stream is not structurally valid.
471    #[error(transparent)]
472    Framing(#[from] FrameError),
473    /// An effective member path or link target is not UTF-8 text.
474    #[error("at byte {position}: {field} is not valid UTF-8")]
475    InvalidUtf8 {
476        /// Source tar block position.
477        position: u64,
478        /// Metadata field being decoded.
479        field: &'static str,
480    },
481    /// A structurally valid tar feature was rejected by decode policy.
482    #[error("at byte {position}: decode policy rejected input: {violation}")]
483    PolicyViolation {
484        /// Source header position for the rejected feature.
485        position: u64,
486        /// The selected policy rule that rejected the feature.
487        violation: DecodePolicyViolation,
488    },
489}
490
491impl DecodeError {
492    fn policy_violation(position: u64, violation: DecodePolicyViolation) -> Self {
493        Self::PolicyViolation {
494            position,
495            violation,
496        }
497    }
498}
499
500/// A tar member payload adapted to [`MemberPayloadTrait`].
501pub struct TarMemberPayload<'a, R> {
502    payload: FramingMemberPayload<'a, R>,
503}
504
505impl<R: AsyncRead + Unpin> MemberPayloadTrait for TarMemberPayload<'_, R> {
506    type Error = DecodeError;
507
508    async fn next_chunk(
509        &mut self,
510        buffer: &mut Vec<u8>,
511        target_len: usize,
512    ) -> Result<bool, Self::Error> {
513        self.payload
514            .next_chunk(buffer, target_len)
515            .await
516            .map_err(Into::into)
517    }
518
519    async fn skip(self) -> Result<(), Self::Error> {
520        self.payload.skip().await.map_err(Into::into)
521    }
522}
523
524impl<R: AsyncRead + Unpin> ArchiveTrait for TarArchive<R> {
525    type Error = DecodeError;
526    type Payload<'a>
527        = TarMemberPayload<'a, R>
528    where
529        Self: 'a;
530
531    async fn next_member<'a>(
532        &'a mut self,
533    ) -> Result<Option<Member<Self::Payload<'a>>>, Self::Error> {
534        if self.fused {
535            return Ok(None);
536        }
537
538        let frame = match self.reader.next_frame().await {
539            Ok(Some(frame)) => frame,
540            Ok(None) => {
541                self.fused = true;
542                return Ok(None);
543            }
544            Err(error) => {
545                self.fused = true;
546                return Err(error.into());
547            }
548        };
549
550        if let Err(error) = self.policy.check_member(&frame) {
551            self.fused = true;
552            return Err(error);
553        }
554
555        match project_member(frame) {
556            Ok(member) => Ok(Some(member)),
557            Err(error) => {
558                self.fused = true;
559                Err(error)
560            }
561        }
562    }
563}
564
565fn project_member<'a, R>(
566    frame: MemberFrame<'a, R>,
567) -> Result<Member<TarMemberPayload<'a, R>>, DecodeError> {
568    let position = frame.header.position;
569    let kind = frame.header.kind;
570    let size = frame.header.effective_size;
571    let executable = frame.header.mode.unwrap_or_default() & 0o111 != 0;
572    let path = std::str::from_utf8(frame.effective_path()?.as_ref())
573        .map(str::to_owned)
574        .map_err(|_| DecodeError::InvalidUtf8 {
575            position,
576            field: "path",
577        })?;
578    let target = if matches!(kind, UstarKind::HardLink | UstarKind::SymbolicLink) {
579        std::str::from_utf8(frame.effective_link_path()?.as_ref())
580            .map(str::to_owned)
581            .map_err(|_| DecodeError::InvalidUtf8 {
582                position,
583                field: "linkpath",
584            })?
585    } else {
586        String::new()
587    };
588    let metadata = MemberMetadata { path, position };
589
590    Ok(match kind {
591        UstarKind::Regular | UstarKind::Contiguous => Member::File {
592            metadata,
593            size,
594            executable,
595            payload: TarMemberPayload {
596                payload: frame.payload,
597            },
598        },
599        UstarKind::Directory => Member::Directory { metadata },
600        UstarKind::SymbolicLink => Member::SymbolicLink { metadata, target },
601        UstarKind::HardLink => Member::HardLink {
602            metadata,
603            target,
604            size,
605            payload: TarMemberPayload {
606                payload: frame.payload,
607            },
608        },
609        UstarKind::CharacterDevice => Member::Special {
610            metadata,
611            kind: SpecialKind::CharacterDevice,
612        },
613        UstarKind::BlockDevice => Member::Special {
614            metadata,
615            kind: SpecialKind::BlockDevice,
616        },
617        UstarKind::Fifo => Member::Special {
618            metadata,
619            kind: SpecialKind::Fifo,
620        },
621    })
622}