Skip to main content

tzap_core/
entry_metadata.rs

1//! Revision-45 per-entry metadata, canonical PAX, and auxiliary-stream rules.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6use sha2::{Digest, Sha256};
7use unicode_normalization::UnicodeNormalization;
8
9use crate::format::FormatError;
10
11pub const EXTENDED_METADATA_V1: u32 = 1 << 0;
12pub const HAS_AUXILIARY_STREAMS: u32 = 1 << 1;
13pub const HAS_NATIVE_METADATA: u32 = 1 << 2;
14pub const HAS_SPARSE_EXTENTS: u32 = 1 << 3;
15pub const CAPTURE_PARTIAL: u32 = 1 << 4;
16pub const REQUIRES_SYSTEM_RESTORE: u32 = 1 << 5;
17pub const FILE_ENTRY_KNOWN_FLAGS: u32 = (1 << 6) - 1;
18
19pub const MAX_PROFILE_COUNT: usize = 64;
20pub const MAX_PROFILE_ID_LEN: usize = 64;
21pub const MAX_AUXILIARY_COUNT: usize = 65_535;
22pub const MAX_AUXILIARY_NAME_LEN: usize = 65_535;
23pub const MAX_LOCAL_PAX_PAYLOAD: usize = 64 * 1024 * 1024;
24pub const MAX_AGGREGATE_PAX_PAYLOAD: usize = 128 * 1024 * 1024;
25pub const MAX_CAPTURE_REPORT_ROWS: usize = 1_048_576;
26pub const MAX_SPARSE_EXTENTS: usize = 1_048_576;
27
28pub const PORTABLE_PROFILE: &str = "portable-v1";
29pub const POSIX_PROFILE: &str = "posix-backup-v1";
30pub const LINUX_PROFILE: &str = "linux-backup-v1";
31pub const MACOS_PROFILE: &str = "macos-backup-v1";
32pub const WINDOWS_PROFILE: &str = "windows-backup-v1";
33pub const CORE_PROFILE: &str = "tzap-core-v1";
34pub const CAPTURE_REPORT_KIND: &str = "tzap.capture-report";
35
36pub type PaxRecords = BTreeMap<String, Vec<u8>>;
37
38/// A revision-45 timestamp represented as signed Unix seconds plus nanoseconds.
39///
40/// `nanoseconds` must be less than one billion. Writers validate this invariant
41/// before serializing the value.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub struct ArchiveTimestamp {
44    pub seconds: i64,
45    pub nanoseconds: u32,
46}
47
48impl ArchiveTimestamp {
49    pub const UNIX_EPOCH: Self = Self::from_seconds(0);
50
51    pub const fn from_seconds(seconds: i64) -> Self {
52        Self {
53            seconds,
54            nanoseconds: 0,
55        }
56    }
57
58    pub const fn new(seconds: i64, nanoseconds: u32) -> Self {
59        Self {
60            seconds,
61            nanoseconds,
62        }
63    }
64
65    pub fn canonical_pax_value(self) -> Result<Vec<u8>, FormatError> {
66        if self.nanoseconds >= 1_000_000_000 {
67            return Err(FormatError::WriterUnsupported(
68                "timestamp nanoseconds must be less than one billion",
69            ));
70        }
71        if self.nanoseconds == 0 {
72            return Ok(self.seconds.to_string().into_bytes());
73        }
74        let mut buffer = Vec::with_capacity(32);
75        use std::io::Write;
76        write!(&mut buffer, "{}.{:09}", self.seconds, self.nanoseconds).unwrap();
77        while buffer.last() == Some(&b'0') {
78            buffer.pop();
79        }
80        Ok(buffer)
81    }
82}
83
84impl fmt::Display for ArchiveTimestamp {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        if self.nanoseconds == 0 {
87            return write!(formatter, "{}", self.seconds);
88        }
89        let fraction = format!("{:09}", self.nanoseconds);
90        write!(
91            formatter,
92            "{}.{fraction}",
93            self.seconds,
94            fraction = fraction.trim_end_matches('0')
95        )
96    }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum CaptureStatus {
101    Complete,
102    Partial,
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
106pub enum RestorePolicy {
107    Content,
108    #[default]
109    Portable,
110    SameOs,
111    System,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
115pub enum RestoreClass {
116    None,
117    Portable,
118    SameOs,
119    System,
120}
121
122impl RestoreClass {
123    fn parse(value: &[u8]) -> Result<Self, FormatError> {
124        match value {
125            b"none" => Ok(Self::None),
126            b"portable" => Ok(Self::Portable),
127            b"same-os" => Ok(Self::SameOs),
128            b"system" => Ok(Self::System),
129            _ => invalid("AuxiliaryMetadata", "invalid restore class"),
130        }
131    }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct MetadataDeclaration {
136    pub required_profiles: Vec<String>,
137    pub optional_profiles: Vec<String>,
138    pub source_os: String,
139    pub source_filesystem: String,
140    pub capture_status: CaptureStatus,
141    pub owner_kind_posix: bool,
142    pub mode_origin_native: bool,
143    pub portable_mode: u32,
144    pub portable_attributes: Option<u32>,
145}
146
147impl MetadataDeclaration {
148    pub fn profile_selected(&self, profile: &str) -> bool {
149        self.required_profiles
150            .binary_search_by(|candidate| candidate.as_str().cmp(profile))
151            .is_ok()
152            || self
153                .optional_profiles
154                .binary_search_by(|candidate| candidate.as_str().cmp(profile))
155                .is_ok()
156    }
157
158    pub fn profile_required(&self, profile: &str) -> bool {
159        self.required_profiles
160            .binary_search_by(|candidate| candidate.as_str().cmp(profile))
161            .is_ok()
162    }
163
164    pub fn has_unknown_required_profile(&self) -> bool {
165        self.required_profiles
166            .iter()
167            .any(|profile| !is_known_profile(profile))
168    }
169
170    pub fn unknown_required_profiles(&self) -> impl Iterator<Item = &str> {
171        self.required_profiles
172            .iter()
173            .map(String::as_str)
174            .filter(|profile| !is_known_profile(profile))
175    }
176
177    pub fn unknown_optional_profiles(&self) -> impl Iterator<Item = &str> {
178        self.optional_profiles
179            .iter()
180            .map(String::as_str)
181            .filter(|profile| !is_known_profile(profile))
182    }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct SparseExtent {
187    pub offset: u64,
188    pub length: u64,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct SparseLayout {
193    pub logical_size: u64,
194    pub map_and_padding_size: usize,
195    pub extents: Vec<SparseExtent>,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct AuxiliaryRecord {
200    pub ordinal: u32,
201    pub kind: String,
202    pub profile: String,
203    pub restore_class: RestoreClass,
204    pub native: bool,
205    pub name_encoding: String,
206    pub decoded_name: Vec<u8>,
207    pub flags: u64,
208    pub logical_size: u64,
209    pub stored_size: u64,
210    pub sha256: [u8; 32],
211    pub meta: BTreeMap<String, Vec<u8>>,
212    pub sparse_layout: Option<SparseLayout>,
213    pub capture_report_payload: Option<Vec<u8>>,
214}
215
216/// Incrementally validates one revision-45 auxiliary payload. Large ordinary
217/// and sparse streams are hashed and structurally checked without retaining
218/// their data bytes.
219pub struct AuxiliaryStreamValidator {
220    record: AuxiliaryRecord,
221    hasher: Sha256,
222    received: u64,
223    sparse: Option<SparseStreamValidator>,
224    retained: Option<Vec<u8>>,
225    retained_cap: usize,
226}
227
228impl AuxiliaryStreamValidator {
229    pub fn new(records: &PaxRecords, ordinal: u32, stored_size: u64) -> Result<Self, FormatError> {
230        let record = parse_auxiliary_declaration(records, ordinal, stored_size)?;
231        let sparse =
232            (record.flags & 1 != 0).then(|| SparseStreamValidator::new(record.logical_size));
233        let retained_cap = retained_auxiliary_cap(&record.kind);
234        let retained = (retained_cap != 0).then(Vec::new);
235        Ok(Self {
236            record,
237            hasher: Sha256::new(),
238            received: 0,
239            sparse,
240            retained,
241            retained_cap,
242        })
243    }
244
245    pub fn observe(&mut self, bytes: &[u8]) -> Result<(), FormatError> {
246        self.received =
247            self.received
248                .checked_add(bytes.len() as u64)
249                .ok_or(FormatError::InvalidArchive(
250                    "auxiliary payload size overflow",
251                ))?;
252        if self.received > self.record.stored_size {
253            return invalid(
254                "AuxiliaryMetadata",
255                "auxiliary payload exceeds declared size",
256            );
257        }
258        self.hasher.update(bytes);
259        if let Some(sparse) = &mut self.sparse {
260            sparse.observe(bytes)?;
261        }
262        if let Some(retained) = &mut self.retained {
263            let next =
264                retained
265                    .len()
266                    .checked_add(bytes.len())
267                    .ok_or(FormatError::InvalidArchive(
268                        "auxiliary retention size overflow",
269                    ))?;
270            if next > self.retained_cap {
271                return Err(FormatError::ReaderResourceLimitExceeded {
272                    field: "structured auxiliary payload bytes",
273                    cap: self.retained_cap as u64,
274                    actual: next as u64,
275                });
276            }
277            retained.extend_from_slice(bytes);
278        }
279        Ok(())
280    }
281
282    pub(crate) fn declaration(&self) -> &AuxiliaryRecord {
283        &self.record
284    }
285
286    pub fn finish(mut self) -> Result<AuxiliaryRecord, FormatError> {
287        if self.received != self.record.stored_size {
288            return invalid("AuxiliaryMetadata", "auxiliary payload length mismatch");
289        }
290        if self.hasher.finalize().as_slice() != self.record.sha256 {
291            return invalid("AuxiliaryMetadata", "auxiliary payload SHA-256 mismatch");
292        }
293        self.record.sparse_layout = self.sparse.map(SparseStreamValidator::finish).transpose()?;
294        // Retain only the bounded structured kinds selected by
295        // `retained_auxiliary_cap`. The historical field name predates native
296        // reparse restoration; consumers still gate interpretation by kind.
297        self.record.capture_report_payload = self.retained.clone();
298        validate_builtin_auxiliary_payload(&self.record, self.retained.as_deref())?;
299        Ok(self.record)
300    }
301}
302
303pub(crate) struct SparseStreamValidator {
304    logical_size: u64,
305    position: u64,
306    line: Vec<u8>,
307    extent_count: Option<usize>,
308    values_remaining: usize,
309    pending_offset: Option<u64>,
310    previous_end: u64,
311    stored_extent_bytes: u64,
312    extents: Vec<SparseExtent>,
313    map_and_padding_size: Option<u64>,
314}
315
316impl SparseStreamValidator {
317    pub(crate) fn new(logical_size: u64) -> Self {
318        Self {
319            logical_size,
320            position: 0,
321            line: Vec::new(),
322            extent_count: None,
323            values_remaining: 0,
324            pending_offset: None,
325            previous_end: 0,
326            stored_extent_bytes: 0,
327            extents: Vec::new(),
328            map_and_padding_size: None,
329        }
330    }
331
332    pub(crate) fn observe(&mut self, bytes: &[u8]) -> Result<(), FormatError> {
333        for byte in bytes {
334            if let Some(padded_end) = self.map_and_padding_size {
335                if self.position < padded_end && *byte != 0 {
336                    return invalid("SparsePayload", "sparse map padding is invalid");
337                }
338                self.position = self
339                    .position
340                    .checked_add(1)
341                    .ok_or(FormatError::InvalidArchive("sparse payload size overflow"))?;
342                continue;
343            }
344
345            self.position = self
346                .position
347                .checked_add(1)
348                .ok_or(FormatError::InvalidArchive("sparse payload size overflow"))?;
349            if *byte != b'\n' {
350                if self.line.len() == 20 || !byte.is_ascii_digit() {
351                    return invalid("SparsePayload", "sparse map value is not canonical decimal");
352                }
353                self.line.push(*byte);
354                continue;
355            }
356            let value = parse_decimal_u64(&self.line, "sparse map value")?;
357            self.line.clear();
358            if self.extent_count.is_none() {
359                let count = usize::try_from(value).map_err(|_| FormatError::InvalidMetadata {
360                    structure: "sparse extent count",
361                    reason: "decimal value exceeds usize",
362                })?;
363                if count > MAX_SPARSE_EXTENTS {
364                    return Err(FormatError::ReaderResourceLimitExceeded {
365                        field: "sparse extent count",
366                        cap: MAX_SPARSE_EXTENTS as u64,
367                        actual: count as u64,
368                    });
369                }
370                self.extent_count = Some(count);
371                self.values_remaining = count
372                    .checked_mul(2)
373                    .ok_or(FormatError::InvalidArchive("sparse extent count overflow"))?;
374                self.extents.reserve(count);
375            } else if self.values_remaining != 0 {
376                if self.pending_offset.is_none() {
377                    self.pending_offset = Some(value);
378                } else {
379                    let offset = self.pending_offset.take().unwrap();
380                    let length = value;
381                    if length == 0 || offset < self.previous_end {
382                        return invalid("SparsePayload", "extents overlap or have zero length");
383                    }
384                    if !self.extents.is_empty() && offset == self.previous_end {
385                        return invalid("SparsePayload", "adjacent extents are not merged");
386                    }
387                    let end = offset
388                        .checked_add(length)
389                        .ok_or(FormatError::InvalidArchive("sparse extent overflow"))?;
390                    if end > self.logical_size {
391                        return invalid("SparsePayload", "extent exceeds logical size");
392                    }
393                    self.stored_extent_bytes = self
394                        .stored_extent_bytes
395                        .checked_add(length)
396                        .ok_or(FormatError::InvalidArchive("sparse stored size overflow"))?;
397                    self.previous_end = end;
398                    self.extents.push(SparseExtent { offset, length });
399                }
400                self.values_remaining -= 1;
401            } else {
402                return invalid("SparsePayload", "sparse map has trailing lines");
403            }
404
405            if self.extent_count.is_some() && self.values_remaining == 0 {
406                self.map_and_padding_size = Some(
407                    self.position
408                        .checked_add(511)
409                        .ok_or(FormatError::InvalidArchive("sparse map padding overflow"))?
410                        / 512
411                        * 512,
412                );
413            }
414        }
415        Ok(())
416    }
417
418    pub(crate) fn layout_if_map_complete(&self) -> Option<SparseLayout> {
419        self.map_and_padding_size.map(|padded| SparseLayout {
420            logical_size: self.logical_size,
421            map_and_padding_size: padded as usize,
422            extents: self.extents.clone(),
423        })
424    }
425
426    pub(crate) fn position(&self) -> u64 {
427        self.position
428    }
429
430    pub(crate) fn finish(self) -> Result<SparseLayout, FormatError> {
431        if !self.line.is_empty() || self.extent_count.is_none() || self.values_remaining != 0 {
432            return invalid("SparsePayload", "sparse map is truncated");
433        }
434        let padded = self
435            .map_and_padding_size
436            .ok_or(FormatError::InvalidArchive("sparse map is missing"))?;
437        if self.position < padded || self.position - padded != self.stored_extent_bytes {
438            return invalid("SparsePayload", "sparse extent bytes do not match map");
439        }
440        Ok(SparseLayout {
441            logical_size: self.logical_size,
442            map_and_padding_size: usize::try_from(padded).map_err(|_| {
443                FormatError::InvalidArchive("sparse map size exceeds platform limits")
444            })?,
445            extents: self.extents,
446        })
447    }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub struct CaptureReportRow {
452    pub profile: String,
453    pub metadata_class: String,
454    pub reason: String,
455    pub encoded_detail: String,
456}
457
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct PrimaryMetadata {
460    pub declaration: MetadataDeclaration,
461    pub path: Option<Vec<u8>>,
462    pub linkpath: Option<Vec<u8>>,
463    pub stored_size: Option<u64>,
464    pub mtime: Option<(i64, u32)>,
465    pub sparse_logical_size: Option<u64>,
466    pub has_native_scalar: bool,
467    pub requires_system_restore: bool,
468    pub xattr_names: Vec<Vec<u8>>,
469}
470
471/// Decoded portable fields that hardlink aliases must mirror exactly.
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct PortableMetadataMirror {
474    pub owner_kind_posix: bool,
475    pub mode_origin_native: bool,
476    pub mode: u32,
477    pub attributes: Option<u32>,
478    pub uid: Option<u64>,
479    pub gid: Option<u64>,
480    pub uname: Option<Vec<u8>>,
481    pub gname: Option<Vec<u8>>,
482    pub mtime: (i64, u32),
483}
484
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct MemberMetadata {
487    pub declaration: MetadataDeclaration,
488    /// Authenticated canonical primary PAX records retained for native restore.
489    pub primary_records: PaxRecords,
490    pub auxiliary: Vec<AuxiliaryRecord>,
491    pub file_entry_flags: u32,
492    pub sparse_layout: Option<SparseLayout>,
493    pub capture_report: Option<Vec<CaptureReportRow>>,
494    pub primary_has_native_scalar: bool,
495    pub primary_requires_system_restore: bool,
496    pub portable_mirror: PortableMetadataMirror,
497}
498
499pub fn parse_canonical_pax(payload: &[u8]) -> Result<PaxRecords, FormatError> {
500    if payload.is_empty() {
501        return invalid("PAX", "payload is empty");
502    }
503    if payload.len() > MAX_LOCAL_PAX_PAYLOAD {
504        return Err(FormatError::ReaderResourceLimitExceeded {
505            field: "local PAX payload bytes",
506            cap: MAX_LOCAL_PAX_PAYLOAD as u64,
507            actual: payload.len() as u64,
508        });
509    }
510
511    let mut records = BTreeMap::new();
512    let mut previous_key: Option<String> = None;
513    let mut cursor = 0usize;
514    while cursor < payload.len() {
515        let relative_space = payload[cursor..]
516            .iter()
517            .position(|byte| *byte == b' ')
518            .ok_or(FormatError::InvalidArchive("PAX record length is missing"))?;
519        let space = cursor
520            .checked_add(relative_space)
521            .ok_or(FormatError::InvalidArchive("PAX arithmetic overflow"))?;
522        let digits = &payload[cursor..space];
523        if digits.is_empty()
524            || !digits.iter().all(u8::is_ascii_digit)
525            || (digits.len() > 1 && digits[0] == b'0')
526        {
527            return invalid("PAX", "record length is not minimal decimal");
528        }
529        let declared = parse_decimal_usize(digits, "PAX record length")?;
530        if declared.to_string().as_bytes() != digits {
531            return invalid("PAX", "record length is not canonical");
532        }
533        let end = cursor
534            .checked_add(declared)
535            .ok_or(FormatError::InvalidArchive("PAX arithmetic overflow"))?;
536        if end > payload.len() || end <= space + 2 || payload[end - 1] != b'\n' {
537            return invalid("PAX", "record length does not frame one record");
538        }
539        let body = &payload[space + 1..end - 1];
540        let equals =
541            body.iter()
542                .position(|byte| *byte == b'=')
543                .ok_or(FormatError::InvalidArchive(
544                    "PAX record equals sign is missing",
545                ))?;
546        let key_bytes = &body[..equals];
547        let value = &body[equals + 1..];
548        if key_bytes.is_empty()
549            || !key_bytes
550                .iter()
551                .all(|byte| (0x21..=0x7e).contains(byte) && *byte != b'=')
552        {
553            return invalid("PAX", "key is not canonical ASCII");
554        }
555        if value.contains(&0) {
556            return invalid("PAX", "value contains NUL");
557        }
558        let key = std::str::from_utf8(key_bytes)
559            .map_err(|_| FormatError::InvalidArchive("PAX key is not ASCII"))?
560            .to_owned();
561        if previous_key
562            .as_ref()
563            .is_some_and(|previous| previous >= &key)
564        {
565            return invalid("PAX", "keys are not strictly sorted and unique");
566        }
567        previous_key = Some(key.clone());
568        records.insert(key, value.to_vec());
569        cursor = end;
570    }
571    Ok(records)
572}
573
574pub fn encode_canonical_pax(records: &PaxRecords) -> Result<Vec<u8>, FormatError> {
575    if records.is_empty() {
576        return Err(FormatError::WriterInvariant("PAX payload cannot be empty"));
577    }
578    let mut out = Vec::new();
579    for (key, value) in records {
580        if key.is_empty()
581            || !key
582                .as_bytes()
583                .iter()
584                .all(|byte| (0x21..=0x7e).contains(byte) && *byte != b'=')
585            || value.contains(&0)
586        {
587            return Err(FormatError::WriterInvariant("invalid canonical PAX record"));
588        }
589        let body_len = key
590            .len()
591            .checked_add(value.len())
592            .and_then(|value| value.checked_add(3))
593            .ok_or(FormatError::WriterInvariant("PAX record length overflow"))?;
594        let mut digits = 1usize;
595        let record_len = loop {
596            let length = digits
597                .checked_add(body_len)
598                .ok_or(FormatError::WriterInvariant("PAX record length overflow"))?;
599            let next_digits = length.to_string().len();
600            if next_digits == digits {
601                break length;
602            }
603            digits = next_digits;
604        };
605        out.extend_from_slice(record_len.to_string().as_bytes());
606        out.push(b' ');
607        out.extend_from_slice(key.as_bytes());
608        out.push(b'=');
609        out.extend_from_slice(value);
610        out.push(b'\n');
611    }
612    if out.len() > MAX_LOCAL_PAX_PAYLOAD {
613        return Err(FormatError::WriterUnsupported(
614            "local PAX payload exceeds revision-45 limit",
615        ));
616    }
617    Ok(out)
618}
619
620pub fn portable_primary_pax(
621    path: &[u8],
622    mode: u32,
623    source_os: &str,
624    path_requires_override: bool,
625) -> Result<PaxRecords, FormatError> {
626    if mode & !0x0fff != 0 {
627        return Err(FormatError::WriterUnsupported(
628            "portable mode contains bits outside revision-45 mode mask",
629        ));
630    }
631    if !is_source_os(source_os) {
632        return Err(FormatError::WriterUnsupported("invalid metadata source OS"));
633    }
634    let mut records = PaxRecords::new();
635    records.insert("TZAP.metadata.capture-status".into(), b"complete".to_vec());
636    records.insert("TZAP.metadata.optional-profiles".into(), Vec::new());
637    records.insert(
638        "TZAP.metadata.required-profiles".into(),
639        PORTABLE_PROFILE.as_bytes().to_vec(),
640    );
641    records.insert(
642        "TZAP.metadata.source-filesystem".into(),
643        b"unknown".to_vec(),
644    );
645    records.insert(
646        "TZAP.metadata.source-os".into(),
647        source_os.as_bytes().to_vec(),
648    );
649    records.insert("TZAP.metadata.version".into(), b"1".to_vec());
650    records.insert(
651        "TZAP.portable.mode".into(),
652        hex::encode(mode.to_be_bytes()).into_bytes(),
653    );
654    records.insert("TZAP.portable.mode-origin".into(), b"projected".to_vec());
655    records.insert("TZAP.portable.owner-kind".into(), b"none".to_vec());
656    if path_requires_override {
657        records.insert("path".into(), path.to_vec());
658    }
659    Ok(records)
660}
661
662pub fn parse_primary_metadata(records: &PaxRecords) -> Result<PrimaryMetadata, FormatError> {
663    validate_primary_key_registry(records)?;
664    expect_value(records, "TZAP.metadata.version", b"1", "PrimaryMetadata")?;
665    let required_profiles =
666        parse_profile_list(required(records, "TZAP.metadata.required-profiles")?)?;
667    let optional_profiles =
668        parse_profile_list(required(records, "TZAP.metadata.optional-profiles")?)?;
669    validate_profile_sets(&required_profiles, &optional_profiles)?;
670
671    let source_os = ascii_string(required(records, "TZAP.metadata.source-os")?, "source OS")?;
672    if !is_source_os(&source_os) {
673        return invalid("PrimaryMetadata", "unknown source OS");
674    }
675    let source_filesystem = ascii_string(
676        required(records, "TZAP.metadata.source-filesystem")?,
677        "source filesystem",
678    )?;
679    if !valid_filesystem_token(&source_filesystem) {
680        return invalid("PrimaryMetadata", "invalid source filesystem token");
681    }
682    validate_profile_dependencies(&required_profiles, &optional_profiles, &source_os)?;
683
684    let capture_status = match required(records, "TZAP.metadata.capture-status")? {
685        b"complete" => CaptureStatus::Complete,
686        b"partial" => CaptureStatus::Partial,
687        _ => return invalid("PrimaryMetadata", "invalid capture status"),
688    };
689    let owner_kind_posix = match required(records, "TZAP.portable.owner-kind")? {
690        b"none" => false,
691        b"posix" => true,
692        _ => return invalid("PrimaryMetadata", "invalid portable owner kind"),
693    };
694    let mode_origin_native = match required(records, "TZAP.portable.mode-origin")? {
695        b"projected" => false,
696        b"native" => true,
697        _ => return invalid("PrimaryMetadata", "invalid portable mode origin"),
698    };
699    let portable_mode =
700        parse_fixed_hex_u32(required(records, "TZAP.portable.mode")?, 8, "portable mode")?;
701    if portable_mode & !0x0fff != 0 {
702        return invalid("PrimaryMetadata", "portable mode has reserved bits");
703    }
704    let portable_attributes = records
705        .get("TZAP.portable.attributes")
706        .map(|value| parse_fixed_hex_u32(value, 8, "portable attributes"))
707        .transpose()?;
708    if portable_attributes.is_some_and(|value| value & !0x0f != 0) {
709        return invalid("PrimaryMetadata", "portable attributes have reserved bits");
710    }
711
712    validate_owner_fields(records, owner_kind_posix)?;
713    let xattr_names = validate_scalar_encodings(records)?;
714    validate_acl_fields(records)?;
715    validate_profile_owned_primary_fields(
716        records,
717        &required_profiles,
718        &optional_profiles,
719        &source_os,
720    )?;
721
722    let path = records.get("path").cloned();
723    let linkpath = records.get("linkpath").cloned();
724    let stored_size = records
725        .get("size")
726        .map(|value| parse_decimal_u64(value, "PAX size"))
727        .transpose()?;
728    let mtime = records
729        .get("mtime")
730        .map(|value| parse_timestamp(value))
731        .transpose()?;
732
733    let sparse_keys = [
734        "GNU.sparse.major",
735        "GNU.sparse.minor",
736        "GNU.sparse.name",
737        "GNU.sparse.realsize",
738    ];
739    let sparse_count = sparse_keys
740        .iter()
741        .filter(|key| records.contains_key(**key))
742        .count();
743    let sparse_logical_size = if sparse_count == 0 {
744        None
745    } else {
746        if sparse_count != sparse_keys.len() {
747            return invalid("PrimaryMetadata", "incomplete GNU sparse 1.0 declaration");
748        }
749        expect_value(records, "GNU.sparse.major", b"1", "PrimaryMetadata")?;
750        expect_value(records, "GNU.sparse.minor", b"0", "PrimaryMetadata")?;
751        Some(parse_decimal_u64(
752            required(records, "GNU.sparse.realsize")?,
753            "GNU sparse logical size",
754        )?)
755    };
756
757    let declaration = MetadataDeclaration {
758        required_profiles,
759        optional_profiles,
760        source_os,
761        source_filesystem,
762        capture_status,
763        owner_kind_posix,
764        mode_origin_native,
765        portable_mode,
766        portable_attributes,
767    };
768    let has_native_scalar = records.keys().any(is_native_primary_key);
769    let windows_stream_security = records
770        .get("TZAP.windows.data-stream-attributes")
771        .map(|value| parse_fixed_hex_u32(value, 8, "Windows stream attributes"))
772        .transpose()?
773        .is_some_and(|value| value & 0x0000_0002 != 0);
774    let requires_system_restore = owner_kind_posix
775        || portable_mode & 0o6000 != 0
776        || windows_stream_security
777        || xattr_names
778            .iter()
779            .any(|name| system_xattr_namespace(name, &declaration.source_os))
780        || has_no_change_flags(records)?
781        || records.keys().any(is_system_primary_key);
782    Ok(PrimaryMetadata {
783        declaration,
784        path,
785        linkpath,
786        stored_size,
787        mtime,
788        sparse_logical_size,
789        has_native_scalar,
790        requires_system_restore,
791        xattr_names,
792    })
793}
794
795pub fn parse_auxiliary_record(
796    records: &PaxRecords,
797    ordinal: u32,
798    stored_size: u64,
799    payload: &[u8],
800) -> Result<AuxiliaryRecord, FormatError> {
801    let mut validator = AuxiliaryStreamValidator::new(records, ordinal, stored_size)?;
802    validator.observe(payload)?;
803    validator.finish()
804}
805
806fn parse_auxiliary_declaration(
807    records: &PaxRecords,
808    ordinal: u32,
809    stored_size: u64,
810) -> Result<AuxiliaryRecord, FormatError> {
811    let structure = "AuxiliaryMetadata";
812    expect_value(records, "TZAP.aux.version", b"1", structure)?;
813    for key in records.keys() {
814        if !matches!(
815            key.as_str(),
816            "TZAP.aux.version"
817                | "TZAP.aux.kind"
818                | "TZAP.aux.profile"
819                | "TZAP.aux.restore-class"
820                | "TZAP.aux.native"
821                | "TZAP.aux.name-encoding"
822                | "TZAP.aux.name"
823                | "TZAP.aux.flags"
824                | "TZAP.aux.logical-size"
825                | "TZAP.aux.sha256"
826                | "size"
827        ) && !key.starts_with("TZAP.aux.meta.")
828        {
829            return invalid(structure, "unregistered auxiliary PAX key");
830        }
831    }
832    let kind = ascii_string(required(records, "TZAP.aux.kind")?, "auxiliary kind")?;
833    if !valid_profile_token(&kind)
834        || !(is_builtin_aux_kind(&kind) || kind.starts_with("x.") && kind.len() > 2)
835    {
836        return invalid(structure, "invalid auxiliary kind");
837    }
838    let profile = ascii_string(required(records, "TZAP.aux.profile")?, "auxiliary profile")?;
839    if profile != CORE_PROFILE && !is_valid_profile_id(&profile) {
840        return invalid(structure, "invalid auxiliary owner profile");
841    }
842    let restore_class = RestoreClass::parse(required(records, "TZAP.aux.restore-class")?)?;
843    let native = match required(records, "TZAP.aux.native")? {
844        b"0" => false,
845        b"1" => true,
846        _ => return invalid(structure, "invalid auxiliary native flag"),
847    };
848    let name_encoding = ascii_string(
849        required(records, "TZAP.aux.name-encoding")?,
850        "auxiliary name encoding",
851    )?;
852    let decoded_name = decode_auxiliary_name(&name_encoding, required(records, "TZAP.aux.name")?)?;
853    if decoded_name.len() > MAX_AUXILIARY_NAME_LEN {
854        return Err(FormatError::ReaderResourceLimitExceeded {
855            field: "decoded auxiliary name bytes",
856            cap: MAX_AUXILIARY_NAME_LEN as u64,
857            actual: decoded_name.len() as u64,
858        });
859    }
860    let flags = parse_fixed_hex_u64(required(records, "TZAP.aux.flags")?, 16, "auxiliary flags")?;
861    if !kind.starts_with("x.") && flags & !1 != 0 {
862        return invalid(structure, "built-in auxiliary flags have reserved bits");
863    }
864    let logical_size = parse_decimal_u64(
865        required(records, "TZAP.aux.logical-size")?,
866        "auxiliary logical size",
867    )?;
868    if let Some(size) = records.get("size") {
869        if parse_decimal_u64(size, "auxiliary PAX size")? != stored_size {
870            return invalid(structure, "PAX size does not match auxiliary stored size");
871        }
872    }
873    let sha256 = parse_fixed_hex_32(required(records, "TZAP.aux.sha256")?)?;
874    if flags & 1 == 0 && logical_size != stored_size {
875        return invalid(structure, "non-sparse logical and stored sizes differ");
876    }
877    let mut meta = BTreeMap::new();
878    for (key, value) in records
879        .iter()
880        .filter(|(key, _)| key.starts_with("TZAP.aux.meta."))
881    {
882        let suffix = &key["TZAP.aux.meta.".len()..];
883        if !valid_profile_token(suffix) {
884            return invalid(structure, "invalid auxiliary metadata field name");
885        }
886        meta.insert(key.clone(), value.clone());
887    }
888    let record = AuxiliaryRecord {
889        ordinal,
890        kind,
891        profile,
892        restore_class,
893        native,
894        name_encoding,
895        decoded_name,
896        flags,
897        logical_size,
898        stored_size,
899        sha256,
900        meta,
901        sparse_layout: None,
902        capture_report_payload: None,
903    };
904    validate_builtin_auxiliary(&record)?;
905    Ok(record)
906}
907
908pub(crate) fn parse_auxiliary_declaration_for_writer(
909    records: &PaxRecords,
910    ordinal: u32,
911    stored_size: u64,
912) -> Result<AuxiliaryRecord, FormatError> {
913    parse_auxiliary_declaration(records, ordinal, stored_size)
914}
915
916pub fn validate_group_metadata(
917    primary: &PrimaryMetadata,
918    auxiliary: &[AuxiliaryRecord],
919) -> Result<(u32, Option<Vec<CaptureReportRow>>), FormatError> {
920    if auxiliary.len() > MAX_AUXILIARY_COUNT {
921        return Err(FormatError::ReaderResourceLimitExceeded {
922            field: "auxiliary record count",
923            cap: MAX_AUXILIARY_COUNT as u64,
924            actual: auxiliary.len() as u64,
925        });
926    }
927    let mut identities = BTreeSet::new();
928    let mut capture_report = None;
929    for record in auxiliary {
930        if record.kind == CAPTURE_REPORT_KIND {
931            if capture_report.is_some() {
932                return invalid("MemberGroup", "duplicate capture report");
933            }
934            if record.profile != CORE_PROFILE {
935                return invalid("MemberGroup", "capture report owner is not tzap-core-v1");
936            }
937        } else if !primary.declaration.profile_selected(&record.profile) {
938            return invalid("MemberGroup", "auxiliary owner profile is not selected");
939        }
940        if record.kind == "generic.xattr" {
941            validate_generic_xattr_declaration(record, &primary.declaration.source_os)?;
942        }
943        let identity = if record.kind.starts_with("x.") {
944            format!("{}\0{}\0", record.profile, record.kind).into_bytes()
945        } else {
946            let mut value = record.kind.as_bytes().to_vec();
947            value.push(0);
948            value
949        };
950        let mut identity = identity;
951        identity.extend_from_slice(&record.decoded_name);
952        if !identities.insert(identity) {
953            return invalid("MemberGroup", "duplicate auxiliary identity");
954        }
955    }
956
957    let report_records: Vec<_> = auxiliary
958        .iter()
959        .filter(|record| record.kind == CAPTURE_REPORT_KIND)
960        .collect();
961    match primary.declaration.capture_status {
962        CaptureStatus::Complete if !report_records.is_empty() => {
963            return invalid("MemberGroup", "complete capture has a capture report")
964        }
965        CaptureStatus::Partial if report_records.len() != 1 => {
966            return invalid("MemberGroup", "partial capture requires one capture report")
967        }
968        _ => {}
969    }
970
971    if let Some(record) = report_records.first() {
972        // The payload has already been hashed by the caller. Capture report parsing is
973        // exposed separately for streaming readers that retain its bounded payload.
974        capture_report = Some(parse_capture_report(
975            record
976                .capture_report_payload
977                .as_deref()
978                .ok_or(FormatError::InvalidArchive(
979                    "capture report payload is missing",
980                ))?,
981            &primary.declaration,
982        )?);
983        if record.flags != 0
984            || record.profile != CORE_PROFILE
985            || record.restore_class != RestoreClass::None
986            || record.native
987            || record.name_encoding != "none"
988            || !record.decoded_name.is_empty()
989        {
990            return invalid("MemberGroup", "capture report declaration is not canonical");
991        }
992    }
993
994    let mut flags = EXTENDED_METADATA_V1;
995    if !auxiliary.is_empty() {
996        flags |= HAS_AUXILIARY_STREAMS;
997    }
998    if primary.declaration.required_profiles != [PORTABLE_PROFILE]
999        || !primary.declaration.optional_profiles.is_empty()
1000        || primary.has_native_scalar
1001        || auxiliary.iter().any(|record| record.native)
1002    {
1003        flags |= HAS_NATIVE_METADATA;
1004    }
1005    if primary.sparse_logical_size.is_some() || auxiliary.iter().any(|record| record.flags & 1 != 0)
1006    {
1007        flags |= HAS_SPARSE_EXTENTS;
1008    }
1009    if primary.declaration.capture_status == CaptureStatus::Partial {
1010        flags |= CAPTURE_PARTIAL;
1011    }
1012    if primary.requires_system_restore
1013        || auxiliary
1014            .iter()
1015            .any(|record| record.restore_class == RestoreClass::System)
1016    {
1017        flags |= REQUIRES_SYSTEM_RESTORE;
1018    }
1019    Ok((flags, capture_report))
1020}
1021
1022pub fn parse_capture_report(
1023    payload: &[u8],
1024    declaration: &MetadataDeclaration,
1025) -> Result<Vec<CaptureReportRow>, FormatError> {
1026    if payload.len() > MAX_LOCAL_PAX_PAYLOAD {
1027        return Err(FormatError::ReaderResourceLimitExceeded {
1028            field: "capture report payload bytes",
1029            cap: MAX_LOCAL_PAX_PAYLOAD as u64,
1030            actual: payload.len() as u64,
1031        });
1032    }
1033    let text = std::str::from_utf8(payload)
1034        .map_err(|_| FormatError::InvalidArchive("capture report is not UTF-8"))?;
1035    if !text.starts_with("tzap-capture-report-v1\n") || !text.ends_with('\n') {
1036        return invalid("CaptureReport", "invalid framing");
1037    }
1038    let mut rows = Vec::new();
1039    let mut previous: Option<&str> = None;
1040    for line in text["tzap-capture-report-v1\n".len()..].split_terminator('\n') {
1041        if line.is_empty() {
1042            return invalid("CaptureReport", "empty row");
1043        }
1044        if rows.len() >= MAX_CAPTURE_REPORT_ROWS {
1045            return Err(FormatError::ReaderResourceLimitExceeded {
1046                field: "capture report rows",
1047                cap: MAX_CAPTURE_REPORT_ROWS as u64,
1048                actual: rows.len() as u64 + 1,
1049            });
1050        }
1051        if previous.is_some_and(|value| value >= line) {
1052            return invalid("CaptureReport", "rows are not strictly sorted and unique");
1053        }
1054        previous = Some(line);
1055        let mut fields = line.split('\t');
1056        let profile = fields.next().unwrap_or_default();
1057        let metadata_class = fields.next().unwrap_or_default();
1058        let reason = fields.next().unwrap_or_default();
1059        let encoded_detail = fields.next().unwrap_or_default();
1060        if fields.next().is_some()
1061            || !declaration.profile_selected(profile)
1062            || !valid_profile_token(metadata_class)
1063            || !matches!(
1064                reason,
1065                "excluded-policy"
1066                    | "unsupported-host"
1067                    | "unsupported-filesystem"
1068                    | "permission-denied"
1069                    | "changed-during-read"
1070                    | "limit-exceeded"
1071                    | "io-error"
1072                    | "invalid-source-metadata"
1073            )
1074            || !valid_percent_encoded_detail(encoded_detail.as_bytes())
1075        {
1076            return invalid("CaptureReport", "invalid row");
1077        }
1078        rows.push(CaptureReportRow {
1079            profile: profile.to_owned(),
1080            metadata_class: metadata_class.to_owned(),
1081            reason: reason.to_owned(),
1082            encoded_detail: encoded_detail.to_owned(),
1083        });
1084    }
1085    if rows.is_empty() {
1086        return invalid("CaptureReport", "report has no rows");
1087    }
1088    Ok(rows)
1089}
1090
1091pub fn parse_sparse_payload(
1092    payload: &[u8],
1093    logical_size: u64,
1094) -> Result<SparseLayout, FormatError> {
1095    let first_newline =
1096        payload
1097            .iter()
1098            .position(|byte| *byte == b'\n')
1099            .ok_or(FormatError::InvalidArchive(
1100                "sparse extent count is missing",
1101            ))?;
1102    let count = parse_decimal_usize(&payload[..first_newline], "sparse extent count")?;
1103    if count > MAX_SPARSE_EXTENTS {
1104        return Err(FormatError::ReaderResourceLimitExceeded {
1105            field: "sparse extent count",
1106            cap: MAX_SPARSE_EXTENTS as u64,
1107            actual: count as u64,
1108        });
1109    }
1110    let mut cursor = first_newline + 1;
1111    let mut extents = Vec::with_capacity(count);
1112    let mut previous_end = 0u64;
1113    let mut stored_extent_bytes = 0u64;
1114    for _ in 0..count {
1115        let offset = parse_sparse_line(payload, &mut cursor)?;
1116        let length = parse_sparse_line(payload, &mut cursor)?;
1117        if length == 0 || offset < previous_end {
1118            return invalid("SparsePayload", "extents overlap or have zero length");
1119        }
1120        if !extents.is_empty() && offset == previous_end {
1121            return invalid("SparsePayload", "adjacent extents are not merged");
1122        }
1123        let end = offset
1124            .checked_add(length)
1125            .ok_or(FormatError::InvalidArchive("sparse extent overflow"))?;
1126        if end > logical_size {
1127            return invalid("SparsePayload", "extent exceeds logical size");
1128        }
1129        stored_extent_bytes = stored_extent_bytes
1130            .checked_add(length)
1131            .ok_or(FormatError::InvalidArchive("sparse stored size overflow"))?;
1132        extents.push(SparseExtent { offset, length });
1133        previous_end = end;
1134    }
1135    let map_and_padding_size = cursor
1136        .checked_add(511)
1137        .ok_or(FormatError::InvalidArchive("sparse map padding overflow"))?
1138        / 512
1139        * 512;
1140    if map_and_padding_size > payload.len()
1141        || payload[cursor..map_and_padding_size]
1142            .iter()
1143            .any(|byte| *byte != 0)
1144    {
1145        return invalid("SparsePayload", "sparse map padding is invalid");
1146    }
1147    if payload.len() as u64 - map_and_padding_size as u64 != stored_extent_bytes {
1148        return invalid("SparsePayload", "sparse extent bytes do not match map");
1149    }
1150    Ok(SparseLayout {
1151        logical_size,
1152        map_and_padding_size,
1153        extents,
1154    })
1155}
1156
1157fn validate_primary_key_registry(records: &PaxRecords) -> Result<(), FormatError> {
1158    for key in records.keys() {
1159        let allowed = matches!(
1160            key.as_str(),
1161            "TZAP.metadata.version"
1162                | "TZAP.metadata.required-profiles"
1163                | "TZAP.metadata.optional-profiles"
1164                | "TZAP.metadata.source-os"
1165                | "TZAP.metadata.source-filesystem"
1166                | "TZAP.metadata.capture-status"
1167                | "TZAP.portable.owner-kind"
1168                | "TZAP.portable.mode-origin"
1169                | "TZAP.portable.mode"
1170                | "TZAP.portable.attributes"
1171                | "path"
1172                | "linkpath"
1173                | "size"
1174                | "uid"
1175                | "gid"
1176                | "uname"
1177                | "gname"
1178                | "mtime"
1179                | "atime"
1180                | "LIBARCHIVE.creationtime"
1181                | "SCHILY.acl.access"
1182                | "SCHILY.acl.default"
1183                | "SCHILY.acl.ace"
1184                | "SCHILY.fflags"
1185                | "GNU.sparse.major"
1186                | "GNU.sparse.minor"
1187                | "GNU.sparse.name"
1188                | "GNU.sparse.realsize"
1189                | "TZAP.unix.ctime-observed"
1190                | "TZAP.windows.change-time"
1191                | "TZAP.posix.device-major"
1192                | "TZAP.posix.device-minor"
1193                | "TZAP.acl.projection"
1194                | "TZAP.acl.syntax"
1195                | "TZAP.linux.fsflags"
1196                | "TZAP.bsd.st-flags"
1197                | "TZAP.macos.st-flags"
1198                | "TZAP.linux.project-id"
1199                | "TZAP.linux.whiteout"
1200                | "TZAP.macos.clone-group"
1201                | "TZAP.windows.file-attributes"
1202                | "TZAP.windows.data-stream-attributes"
1203                | "TZAP.windows.directory-case-sensitive"
1204                | "TZAP.windows.reparse-placeholder"
1205        ) || key.starts_with("LIBARCHIVE.xattr.");
1206        if !allowed {
1207            return invalid("PrimaryMetadata", "unregistered primary PAX key");
1208        }
1209    }
1210    Ok(())
1211}
1212
1213fn parse_profile_list(value: &[u8]) -> Result<Vec<String>, FormatError> {
1214    if value.is_empty() {
1215        return Ok(Vec::new());
1216    }
1217    let text = ascii_string(value, "profile list")?;
1218    let profiles: Vec<_> = text.split(',').map(str::to_owned).collect();
1219    if profiles.len() > MAX_PROFILE_COUNT {
1220        return Err(FormatError::ReaderResourceLimitExceeded {
1221            field: "metadata profiles per list",
1222            cap: MAX_PROFILE_COUNT as u64,
1223            actual: profiles.len() as u64,
1224        });
1225    }
1226    if profiles.iter().any(|profile| !is_valid_profile_id(profile))
1227        || profiles.windows(2).any(|pair| pair[0] >= pair[1])
1228    {
1229        return invalid("PrimaryMetadata", "profile list is not canonical");
1230    }
1231    Ok(profiles)
1232}
1233
1234fn validate_profile_sets(required: &[String], optional: &[String]) -> Result<(), FormatError> {
1235    if required
1236        .binary_search_by(|value| value.as_str().cmp(PORTABLE_PROFILE))
1237        .is_err()
1238    {
1239        return invalid("PrimaryMetadata", "portable-v1 is not required");
1240    }
1241    if required.iter().any(|profile| profile == CORE_PROFILE)
1242        || optional.iter().any(|profile| profile == CORE_PROFILE)
1243        || required
1244            .iter()
1245            .any(|profile| optional.binary_search(profile).is_ok())
1246    {
1247        return invalid(
1248            "PrimaryMetadata",
1249            "profile lists overlap or contain reserved profile",
1250        );
1251    }
1252    Ok(())
1253}
1254
1255fn validate_profile_dependencies(
1256    required: &[String],
1257    optional: &[String],
1258    source_os: &str,
1259) -> Result<(), FormatError> {
1260    let req = |profile: &str| {
1261        required
1262            .binary_search_by(|value| value.as_str().cmp(profile))
1263            .is_ok()
1264    };
1265    let opt = |profile: &str| {
1266        optional
1267            .binary_search_by(|value| value.as_str().cmp(profile))
1268            .is_ok()
1269    };
1270    if (req(LINUX_PROFILE) || req(MACOS_PROFILE)) && !req(POSIX_PROFILE) {
1271        return invalid(
1272            "PrimaryMetadata",
1273            "required native profile dependency is missing",
1274        );
1275    }
1276    if (opt(LINUX_PROFILE) || opt(MACOS_PROFILE)) && !(req(POSIX_PROFILE) || opt(POSIX_PROFILE)) {
1277        return invalid(
1278            "PrimaryMetadata",
1279            "optional native profile dependency is missing",
1280        );
1281    }
1282    if (req(LINUX_PROFILE) || opt(LINUX_PROFILE)) && source_os != "linux"
1283        || (req(MACOS_PROFILE) || opt(MACOS_PROFILE)) && source_os != "macos"
1284        || (req(WINDOWS_PROFILE) || opt(WINDOWS_PROFILE)) && source_os != "windows"
1285    {
1286        return invalid("PrimaryMetadata", "native profile does not match source OS");
1287    }
1288    Ok(())
1289}
1290
1291fn validate_owner_fields(records: &PaxRecords, posix: bool) -> Result<(), FormatError> {
1292    let ownership = ["uid", "gid", "uname", "gname"];
1293    if !posix && ownership.iter().any(|key| records.contains_key(*key)) {
1294        return invalid(
1295            "PrimaryMetadata",
1296            "owner-kind none has POSIX ownership fields",
1297        );
1298    }
1299    if posix {
1300        if let Some(value) = records.get("uid") {
1301            parse_decimal_u64(value, "uid")?;
1302        }
1303        if let Some(value) = records.get("gid") {
1304            parse_decimal_u64(value, "gid")?;
1305        }
1306        for key in ["uname", "gname"] {
1307            if let Some(value) = records.get(key) {
1308                let text = std::str::from_utf8(value)
1309                    .map_err(|_| FormatError::InvalidArchive("owner name is not UTF-8"))?;
1310                if text.nfc().collect::<String>() != text {
1311                    return invalid("PrimaryMetadata", "owner name is not NFC");
1312                }
1313            }
1314        }
1315    }
1316    Ok(())
1317}
1318
1319fn validate_scalar_encodings(records: &PaxRecords) -> Result<Vec<Vec<u8>>, FormatError> {
1320    for key in [
1321        "mtime",
1322        "atime",
1323        "LIBARCHIVE.creationtime",
1324        "TZAP.unix.ctime-observed",
1325        "TZAP.windows.change-time",
1326    ] {
1327        if let Some(value) = records.get(key) {
1328            parse_timestamp(value)?;
1329        }
1330    }
1331    for key in ["TZAP.posix.device-major", "TZAP.posix.device-minor"] {
1332        if let Some(value) = records.get(key) {
1333            parse_decimal_u64(value, "primary decimal scalar")?;
1334        }
1335    }
1336    if let Some(value) = records.get("TZAP.linux.project-id") {
1337        let parsed = parse_decimal_u64(value, "Linux project ID")?;
1338        if parsed > u32::MAX as u64 {
1339            return invalid("PrimaryMetadata", "Linux project ID exceeds u32");
1340        }
1341    }
1342    for key in [
1343        "TZAP.linux.fsflags",
1344        "TZAP.bsd.st-flags",
1345        "TZAP.macos.st-flags",
1346    ] {
1347        if let Some(value) = records.get(key) {
1348            parse_fixed_hex_u64(value, 16, "native flags")?;
1349        }
1350    }
1351    if let Some(value) = records.get("SCHILY.fflags") {
1352        let text = ascii_string(value, "SCHILY file flags")?;
1353        if text.is_empty()
1354            || text
1355                .split(',')
1356                .any(|token| !valid_profile_token(token) || token.bytes().any(|byte| byte == b'.'))
1357            || text
1358                .split(',')
1359                .collect::<Vec<_>>()
1360                .windows(2)
1361                .any(|pair| pair[0] >= pair[1])
1362        {
1363            return invalid("PrimaryMetadata", "SCHILY file flags are not canonical");
1364        }
1365    }
1366    for key in [
1367        "TZAP.windows.file-attributes",
1368        "TZAP.windows.data-stream-attributes",
1369    ] {
1370        if let Some(value) = records.get(key) {
1371            parse_fixed_hex_u32(value, 8, "Windows attributes")?;
1372        }
1373    }
1374    if let Some(value) = records.get("TZAP.macos.clone-group") {
1375        if value.len() != 32 || !value.iter().all(is_lower_hex) {
1376            return invalid("PrimaryMetadata", "invalid macOS clone group");
1377        }
1378    }
1379    for key in ["TZAP.linux.whiteout", "TZAP.windows.reparse-placeholder"] {
1380        if let Some(value) = records.get(key) {
1381            if value != b"1" {
1382                return invalid("PrimaryMetadata", "invalid boolean scalar");
1383            }
1384        }
1385    }
1386    if let Some(value) = records.get("TZAP.windows.directory-case-sensitive") {
1387        if value != b"0" && value != b"1" {
1388            return invalid("PrimaryMetadata", "invalid Windows case-sensitive scalar");
1389        }
1390    }
1391    let mut decoded_xattrs = BTreeSet::new();
1392    for (key, value) in records
1393        .iter()
1394        .filter(|(key, _)| key.starts_with("LIBARCHIVE.xattr."))
1395    {
1396        let name = decode_percent_name(&key.as_bytes()["LIBARCHIVE.xattr.".len()..])?;
1397        if name.len() > MAX_AUXILIARY_NAME_LEN {
1398            return Err(FormatError::ReaderResourceLimitExceeded {
1399                field: "decoded xattr name bytes",
1400                cap: MAX_AUXILIARY_NAME_LEN as u64,
1401                actual: name.len() as u64,
1402            });
1403        }
1404        if name.is_empty() || !decoded_xattrs.insert(name) {
1405            return invalid("PrimaryMetadata", "duplicate or empty decoded xattr name");
1406        }
1407        let decoded = canonical_base64_decode(value)?;
1408        if decoded.len() > MAX_LOCAL_PAX_PAYLOAD {
1409            return Err(FormatError::ReaderResourceLimitExceeded {
1410                field: "decoded xattr value bytes",
1411                cap: MAX_LOCAL_PAX_PAYLOAD as u64,
1412                actual: decoded.len() as u64,
1413            });
1414        }
1415    }
1416    for reserved in [
1417        b"com.apple.ResourceFork".as_slice(),
1418        b"com.apple.FinderInfo".as_slice(),
1419    ] {
1420        if decoded_xattrs.contains(reserved) {
1421            return invalid(
1422                "PrimaryMetadata",
1423                "macOS resource fork or FinderInfo is encoded as a generic xattr",
1424            );
1425        }
1426    }
1427    Ok(decoded_xattrs.into_iter().collect())
1428}
1429
1430fn validate_acl_fields(records: &PaxRecords) -> Result<(), FormatError> {
1431    let posix =
1432        records.contains_key("SCHILY.acl.access") || records.contains_key("SCHILY.acl.default");
1433    let nfs4 = records.contains_key("SCHILY.acl.ace");
1434    if posix && nfs4 {
1435        return invalid("PrimaryMetadata", "multiple textual ACL models are present");
1436    }
1437    let projection = records.get("TZAP.acl.projection");
1438    let syntax = records.get("TZAP.acl.syntax");
1439    if posix || nfs4 {
1440        if !matches!(projection.map(Vec::as_slice), Some(b"exact" | b"lossy")) {
1441            return invalid("PrimaryMetadata", "textual ACL projection is missing");
1442        }
1443        let expected = if posix {
1444            b"schily-posix1e-extra-id-v1".as_slice()
1445        } else {
1446            b"schily-nfs4-full-extra-id-v1".as_slice()
1447        };
1448        if syntax.map(Vec::as_slice) != Some(expected) {
1449            return invalid("PrimaryMetadata", "textual ACL syntax does not match model");
1450        }
1451    } else if (projection.is_some() || syntax.is_some())
1452        && (projection.map(Vec::as_slice) != Some(b"none") || syntax.is_some())
1453    {
1454        return invalid(
1455            "PrimaryMetadata",
1456            "ACL declaration has no matching ACL data",
1457        );
1458    }
1459    for key in ["SCHILY.acl.access", "SCHILY.acl.default"] {
1460        if let Some(value) = records.get(key) {
1461            validate_posix_acl_text(value)?;
1462        }
1463    }
1464    if let Some(value) = records.get("SCHILY.acl.ace") {
1465        validate_nfs4_acl_text(value)?;
1466    }
1467    Ok(())
1468}
1469
1470#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1471struct PosixAclSortKey {
1472    category: u8,
1473    numeric_qualifier: u64,
1474    name: String,
1475}
1476
1477fn validate_posix_acl_text(value: &[u8]) -> Result<(), FormatError> {
1478    let text = canonical_acl_text(value)?;
1479    let mut previous: Option<PosixAclSortKey> = None;
1480    let mut principals = BTreeSet::new();
1481    let mut base = [false; 3];
1482    let mut mask_count = 0usize;
1483    let mut named_count = 0usize;
1484    for serialized in text.split(',') {
1485        let fields: Vec<_> = serialized.split(':').collect();
1486        if !(fields.len() == 3 || fields.len() == 4) {
1487            return invalid("PrimaryMetadata", "POSIX ACL tuple has invalid field count");
1488        }
1489        let tag = fields[0];
1490        let name = fields[1];
1491        validate_posix_permissions(fields[2])?;
1492        let id = if fields.len() == 4 {
1493            Some(parse_acl_id(fields[3])?)
1494        } else {
1495            None
1496        };
1497        let (category, numeric_qualifier) = match (tag, name.is_empty()) {
1498            ("user", true) => {
1499                if id.is_some() || std::mem::replace(&mut base[0], true) {
1500                    return invalid(
1501                        "PrimaryMetadata",
1502                        "duplicate or invalid POSIX owner-user ACL entry",
1503                    );
1504                }
1505                (0, 0)
1506            }
1507            ("group", true) => {
1508                if id.is_some() || std::mem::replace(&mut base[1], true) {
1509                    return invalid(
1510                        "PrimaryMetadata",
1511                        "duplicate or invalid POSIX owner-group ACL entry",
1512                    );
1513                }
1514                (1, 0)
1515            }
1516            ("other", true) => {
1517                if id.is_some() || std::mem::replace(&mut base[2], true) {
1518                    return invalid(
1519                        "PrimaryMetadata",
1520                        "duplicate or invalid POSIX other ACL entry",
1521                    );
1522                }
1523                (2, 0)
1524            }
1525            ("user", false) => {
1526                named_count += 1;
1527                (3, validate_acl_name_and_id(name, id)?)
1528            }
1529            ("group", false) => {
1530                named_count += 1;
1531                (4, validate_acl_name_and_id(name, id)?)
1532            }
1533            ("mask", true) => {
1534                if id.is_some() || mask_count != 0 {
1535                    return invalid("PrimaryMetadata", "duplicate or invalid POSIX ACL mask");
1536                }
1537                mask_count += 1;
1538                (5, 0)
1539            }
1540            _ => return invalid("PrimaryMetadata", "invalid POSIX ACL tag or qualifier"),
1541        };
1542        let key = PosixAclSortKey {
1543            category,
1544            numeric_qualifier,
1545            name: name.to_owned(),
1546        };
1547        if previous.as_ref().is_some_and(|prior| prior >= &key) {
1548            return invalid(
1549                "PrimaryMetadata",
1550                "POSIX ACL entries are not canonically ordered",
1551            );
1552        }
1553        previous = Some(key);
1554        if !principals.insert((tag.to_owned(), name.to_owned(), id)) {
1555            return invalid("PrimaryMetadata", "duplicate POSIX ACL principal");
1556        }
1557    }
1558    if !base.into_iter().all(|present| present) || (named_count != 0 && mask_count != 1) {
1559        return invalid("PrimaryMetadata", "POSIX ACL required entries are missing");
1560    }
1561    Ok(())
1562}
1563
1564fn validate_nfs4_acl_text(value: &[u8]) -> Result<(), FormatError> {
1565    let text = canonical_acl_text(value)?;
1566    let mut tuples = BTreeSet::new();
1567    for serialized in text.split(',') {
1568        if !tuples.insert(serialized) {
1569            return invalid("PrimaryMetadata", "duplicate NFSv4 ACL tuple");
1570        }
1571        let fields: Vec<_> = serialized.split(':').collect();
1572        let named = matches!(fields.first().copied(), Some("user" | "group"));
1573        let expected_fields = if named { 5..=6 } else { 4..=4 };
1574        if !expected_fields.contains(&fields.len()) {
1575            return invalid("PrimaryMetadata", "NFSv4 ACL tuple has invalid field count");
1576        }
1577        let offset = if named {
1578            let id = if fields.len() == 6 {
1579                Some(parse_acl_id(fields[5])?)
1580            } else {
1581                None
1582            };
1583            validate_acl_name_and_id(fields[1], id)?;
1584            1
1585        } else {
1586            if !matches!(fields[0], "owner@" | "group@" | "everyone@") {
1587                return invalid("PrimaryMetadata", "invalid NFSv4 ACL principal");
1588            }
1589            0
1590        };
1591        validate_fixed_acl_bits(fields[1 + offset], b"rwxpDdaARWcCos", "NFSv4 permissions")?;
1592        validate_fixed_acl_bits(fields[2 + offset], b"fdinSFI", "NFSv4 inheritance flags")?;
1593        if !matches!(fields[3 + offset], "allow" | "deny" | "audit" | "alarm") {
1594            return invalid("PrimaryMetadata", "invalid NFSv4 ACL entry type");
1595        }
1596    }
1597    Ok(())
1598}
1599
1600fn canonical_acl_text(value: &[u8]) -> Result<&str, FormatError> {
1601    let text = std::str::from_utf8(value)
1602        .map_err(|_| FormatError::InvalidArchive("ACL text is not UTF-8"))?;
1603    if text.is_empty()
1604        || text.starts_with(',')
1605        || text.ends_with(',')
1606        || text.contains("default:")
1607        || text
1608            .bytes()
1609            .any(|byte| byte.is_ascii_whitespace() || byte == b'#' || byte == 0)
1610    {
1611        return invalid("PrimaryMetadata", "ACL text is not canonical");
1612    }
1613    Ok(text)
1614}
1615
1616fn validate_posix_permissions(value: &str) -> Result<(), FormatError> {
1617    if value.len() != 3
1618        || !value
1619            .bytes()
1620            .zip(*b"rwx")
1621            .all(|(actual, expected)| actual == expected || actual == b'-')
1622    {
1623        return invalid("PrimaryMetadata", "POSIX ACL permissions are not canonical");
1624    }
1625    Ok(())
1626}
1627
1628fn validate_fixed_acl_bits(
1629    value: &str,
1630    positions: &[u8],
1631    label: &'static str,
1632) -> Result<(), FormatError> {
1633    if value.len() != positions.len()
1634        || !value
1635            .bytes()
1636            .zip(positions.iter().copied())
1637            .all(|(actual, expected)| actual == expected || actual == b'-')
1638    {
1639        return invalid("PrimaryMetadata", label);
1640    }
1641    Ok(())
1642}
1643
1644fn parse_acl_id(value: &str) -> Result<u64, FormatError> {
1645    if value.is_empty()
1646        || !value.bytes().all(|byte| byte.is_ascii_digit())
1647        || value.len() > 1 && value.starts_with('0')
1648    {
1649        return invalid("PrimaryMetadata", "ACL numeric ID is not minimal decimal");
1650    }
1651    value
1652        .parse()
1653        .map_err(|_| FormatError::InvalidArchive("ACL numeric ID exceeds u64"))
1654}
1655
1656fn validate_acl_name_and_id(name: &str, id: Option<u64>) -> Result<u64, FormatError> {
1657    if name.is_empty()
1658        || name.nfc().collect::<String>() != name
1659        || name
1660            .bytes()
1661            .any(|byte| matches!(byte, b',' | b':' | b'\r' | b'\n' | 0))
1662    {
1663        return invalid("PrimaryMetadata", "ACL name is not canonical NFC UTF-8");
1664    }
1665    if name.bytes().all(|byte| byte.is_ascii_digit()) {
1666        let numeric_name = parse_acl_id(name)?;
1667        if id.is_some() {
1668            return invalid(
1669                "PrimaryMetadata",
1670                "numeric ACL name has a redundant extra ID",
1671            );
1672        }
1673        Ok(numeric_name)
1674    } else {
1675        Ok(id.unwrap_or(u64::MAX))
1676    }
1677}
1678
1679fn validate_profile_owned_primary_fields(
1680    records: &PaxRecords,
1681    required: &[String],
1682    optional: &[String],
1683    source_os: &str,
1684) -> Result<(), FormatError> {
1685    let selected = |profile: &str| {
1686        required
1687            .binary_search_by(|value| value.as_str().cmp(profile))
1688            .is_ok()
1689            || optional
1690                .binary_search_by(|value| value.as_str().cmp(profile))
1691                .is_ok()
1692    };
1693    let source_profile = match source_os {
1694        "linux" => LINUX_PROFILE,
1695        "macos" => MACOS_PROFILE,
1696        "windows" => WINDOWS_PROFILE,
1697        "freebsd" | "netbsd" | "openbsd" | "solaris" | "other-unix" => POSIX_PROFILE,
1698        _ => "",
1699    };
1700    for key in records.keys() {
1701        let owner = if key.starts_with("TZAP.linux.") {
1702            LINUX_PROFILE
1703        } else if key.starts_with("TZAP.macos.") {
1704            MACOS_PROFILE
1705        } else if key.starts_with("TZAP.windows.") {
1706            WINDOWS_PROFILE
1707        } else if key.starts_with("TZAP.posix.") {
1708            POSIX_PROFILE
1709        } else if key.starts_with("LIBARCHIVE.xattr.") {
1710            let name = decode_percent_name(&key.as_bytes()["LIBARCHIVE.xattr.".len()..])?;
1711            xattr_owner_profile(&name, source_os)
1712        } else if key.starts_with("SCHILY.acl.") || key.starts_with("TZAP.acl.") {
1713            POSIX_PROFILE
1714        } else if key.starts_with("SCHILY.")
1715            || key == "LIBARCHIVE.creationtime"
1716            || key == "TZAP.unix.ctime-observed"
1717        {
1718            source_profile
1719        } else {
1720            ""
1721        };
1722        if !owner.is_empty() && !selected(owner) {
1723            return invalid(
1724                "PrimaryMetadata",
1725                "primary key owner profile is not selected",
1726            );
1727        }
1728    }
1729    Ok(())
1730}
1731
1732fn system_xattr_namespace(name: &[u8], source_os: &str) -> bool {
1733    name.starts_with(b"security.")
1734        || name.starts_with(b"trusted.")
1735        || name.starts_with(b"system.")
1736        || (source_os == "linux" && !name.starts_with(b"user.") && !name.starts_with(b"com.apple."))
1737}
1738
1739fn xattr_owner_profile(name: &[u8], source_os: &str) -> &'static str {
1740    if name.starts_with(b"com.apple.") {
1741        MACOS_PROFILE
1742    } else if source_os == "linux" && system_xattr_namespace(name, source_os) {
1743        LINUX_PROFILE
1744    } else {
1745        POSIX_PROFILE
1746    }
1747}
1748
1749fn validate_builtin_auxiliary(record: &AuxiliaryRecord) -> Result<(), FormatError> {
1750    let structure = "AuxiliaryMetadata";
1751    let fixed = match record.kind.as_str() {
1752        CAPTURE_REPORT_KIND => Some((CORE_PROFILE, RestoreClass::None, false, "none")),
1753        "windows.security-descriptor" => {
1754            Some((WINDOWS_PROFILE, RestoreClass::System, true, "none"))
1755        }
1756        "windows.reparse-data" => Some((WINDOWS_PROFILE, RestoreClass::System, true, "none")),
1757        "windows.object-id" => Some((WINDOWS_PROFILE, RestoreClass::System, true, "none")),
1758        "windows.efs-raw" => Some((WINDOWS_PROFILE, RestoreClass::System, true, "none")),
1759        "macos.resource-fork" => Some((MACOS_PROFILE, RestoreClass::SameOs, true, "none")),
1760        "macos.acl-native" => Some((MACOS_PROFILE, RestoreClass::SameOs, true, "none")),
1761        "macos.finder-info" => Some((MACOS_PROFILE, RestoreClass::SameOs, true, "none")),
1762        "windows.alternate-data" => Some((
1763            WINDOWS_PROFILE,
1764            record.restore_class,
1765            true,
1766            "utf16le-base64",
1767        )),
1768        "windows.ea-data" | "windows.property-data" => {
1769            Some((WINDOWS_PROFILE, record.restore_class, true, "none"))
1770        }
1771        "generic.xattr" => Some((
1772            record.profile.as_str(),
1773            record.restore_class,
1774            true,
1775            "bytes-base64",
1776        )),
1777        "generic.named-fork" => Some((
1778            record.profile.as_str(),
1779            RestoreClass::SameOs,
1780            true,
1781            "bytes-base64",
1782        )),
1783        _ if record.kind.starts_with("x.") => None,
1784        _ => return invalid(structure, "unknown built-in auxiliary kind"),
1785    };
1786    if let Some((profile, class, native, encoding)) = fixed {
1787        if record.profile != profile
1788            || record.restore_class != class
1789            || record.native != native
1790            || record.name_encoding != encoding
1791        {
1792            return invalid(structure, "built-in auxiliary declaration mismatch");
1793        }
1794    }
1795    let required_meta: &[(&str, Option<&[u8]>)] = match record.kind.as_str() {
1796        "windows.security-descriptor" => &[("TZAP.aux.meta.security-information", None)],
1797        "windows.reparse-data" => &[("TZAP.aux.meta.reparse-tag", None)],
1798        "windows.alternate-data" => &[
1799            ("TZAP.aux.meta.stream-type", Some(b"00000004")),
1800            ("TZAP.aux.meta.stream-attributes", None),
1801        ],
1802        "windows.ea-data" => &[
1803            ("TZAP.aux.meta.stream-type", Some(b"00000002")),
1804            ("TZAP.aux.meta.stream-attributes", None),
1805        ],
1806        "windows.property-data" => &[
1807            ("TZAP.aux.meta.stream-type", Some(b"00000006")),
1808            ("TZAP.aux.meta.stream-attributes", None),
1809        ],
1810        "windows.object-id" => &[
1811            ("TZAP.aux.meta.stream-type", Some(b"00000007")),
1812            ("TZAP.aux.meta.stream-attributes", None),
1813        ],
1814        "windows.efs-raw" => &[("TZAP.aux.meta.efs-version", Some(b"1"))],
1815        "macos.acl-native" => &[("TZAP.aux.meta.acl-format", Some(b"darwin-acl-external-v1"))],
1816        _ => &[],
1817    };
1818    if !record.kind.starts_with("x.")
1819        && !matches!(record.kind.as_str(), "generic.xattr" | "generic.named-fork")
1820        && record.meta.len() != required_meta.len()
1821    {
1822        return invalid(structure, "unexpected built-in auxiliary metadata field");
1823    }
1824    for (key, expected) in required_meta {
1825        let value = record.meta.get(*key).ok_or(FormatError::InvalidArchive(
1826            "required auxiliary metadata is missing",
1827        ))?;
1828        if let Some(expected) = expected {
1829            if value != expected {
1830                return invalid(structure, "auxiliary metadata value mismatch");
1831            }
1832        } else if key.ends_with("information")
1833            || key.ends_with("tag")
1834            || key.ends_with("attributes")
1835        {
1836            parse_fixed_hex_u32(value, 8, "auxiliary metadata hexadecimal")?;
1837        }
1838    }
1839    if matches!(
1840        record.kind.as_str(),
1841        "windows.alternate-data"
1842            | "windows.ea-data"
1843            | "windows.property-data"
1844            | "windows.object-id"
1845    ) {
1846        let attributes = parse_fixed_hex_u32(
1847            record.meta.get("TZAP.aux.meta.stream-attributes").ok_or(
1848                FormatError::InvalidArchive("Windows stream attributes are missing"),
1849            )?,
1850            8,
1851            "Windows stream attributes",
1852        )?;
1853        let expected_class = if record.kind == "windows.object-id" || attributes & 0x0000_0002 != 0
1854        {
1855            RestoreClass::System
1856        } else {
1857            RestoreClass::SameOs
1858        };
1859        if record.restore_class != expected_class {
1860            return invalid(
1861                structure,
1862                "Windows stream restore class disagrees with stream attributes",
1863            );
1864        }
1865        if (attributes & 0x0000_0008 != 0) != (record.flags & 1 != 0) {
1866            return invalid(
1867                structure,
1868                "Windows sparse stream attribute disagrees with sparse framing",
1869            );
1870        }
1871    }
1872    if record.kind == "windows.alternate-data" {
1873        validate_windows_stream_name(&record.decoded_name)?;
1874    }
1875    if record.kind == "generic.xattr" {
1876        validate_generic_xattr_name(record)?;
1877    }
1878    if record.flags & 1 != 0
1879        && matches!(
1880            record.kind.as_str(),
1881            CAPTURE_REPORT_KIND
1882                | "windows.security-descriptor"
1883                | "windows.ea-data"
1884                | "windows.reparse-data"
1885                | "windows.object-id"
1886                | "windows.property-data"
1887                | "windows.efs-raw"
1888                | "macos.acl-native"
1889                | "macos.finder-info"
1890        )
1891    {
1892        return invalid(structure, "auxiliary kind cannot use sparse framing");
1893    }
1894    Ok(())
1895}
1896
1897fn retained_auxiliary_cap(kind: &str) -> usize {
1898    match kind {
1899        CAPTURE_REPORT_KIND | "windows.ea-data" => MAX_LOCAL_PAX_PAYLOAD,
1900        "windows.security-descriptor" => 256 * 1024,
1901        "windows.reparse-data" => 16 * 1024,
1902        "windows.object-id" | "macos.finder-info" => 64,
1903        "macos.acl-native" => 40 + 128 * 28,
1904        _ => 0,
1905    }
1906}
1907
1908fn validate_darwin_acl_external(value: &[u8]) -> Result<(), FormatError> {
1909    const HEADER: usize = 40;
1910    const ACE_SIZE: usize = 28;
1911    const MAX_ENTRIES: usize = 128;
1912    const MAGIC: [u8; 4] = [0x01, 0x2c, 0xc1, 0x6d];
1913    if value.get(..4) != Some(MAGIC.as_slice()) {
1914        return invalid("AuxiliaryMetadata", "invalid macOS ACL external magic");
1915    }
1916    let count = value
1917        .get(36..40)
1918        .and_then(|bytes| bytes.try_into().ok())
1919        .map(u32::from_be_bytes)
1920        .ok_or(FormatError::InvalidArchive(
1921            "macOS ACL external form is truncated",
1922        ))? as usize;
1923    let expected = HEADER
1924        .checked_add(
1925            count
1926                .checked_mul(ACE_SIZE)
1927                .ok_or(FormatError::InvalidArchive(
1928                    "macOS ACL entry count overflows",
1929                ))?,
1930        )
1931        .ok_or(FormatError::InvalidArchive("macOS ACL size overflows"))?;
1932    if count > MAX_ENTRIES || expected != value.len() {
1933        return invalid("AuxiliaryMetadata", "invalid macOS ACL external size");
1934    }
1935    #[cfg(target_os = "macos")]
1936    {
1937        extern "C" {
1938            fn acl_copy_int(buffer: *const libc::c_void) -> *mut libc::c_void;
1939            fn acl_free(object: *mut libc::c_void) -> libc::c_int;
1940        }
1941        let acl = unsafe { acl_copy_int(value.as_ptr().cast()) };
1942        if acl.is_null() {
1943            return invalid("AuxiliaryMetadata", "invalid macOS ACL external payload");
1944        }
1945        unsafe { acl_free(acl) };
1946    }
1947    Ok(())
1948}
1949
1950fn validate_generic_xattr_name(record: &AuxiliaryRecord) -> Result<(), FormatError> {
1951    let name = record.decoded_name.as_slice();
1952    if matches!(name, b"com.apple.ResourceFork" | b"com.apple.FinderInfo") {
1953        return invalid(
1954            "AuxiliaryMetadata",
1955            "macOS resource fork or FinderInfo is encoded as a generic xattr",
1956        );
1957    }
1958    Ok(())
1959}
1960
1961fn validate_generic_xattr_declaration(
1962    record: &AuxiliaryRecord,
1963    source_os: &str,
1964) -> Result<(), FormatError> {
1965    validate_generic_xattr_name(record)?;
1966    let name = record.decoded_name.as_slice();
1967    let profile = xattr_owner_profile(name, source_os);
1968    let class = if system_xattr_namespace(name, source_os) {
1969        RestoreClass::System
1970    } else {
1971        RestoreClass::SameOs
1972    };
1973    if record.profile != profile || record.restore_class != class {
1974        return invalid(
1975            "AuxiliaryMetadata",
1976            "generic xattr owner or restore class disagrees with its namespace",
1977        );
1978    }
1979    Ok(())
1980}
1981
1982fn validate_builtin_auxiliary_payload(
1983    record: &AuxiliaryRecord,
1984    retained: Option<&[u8]>,
1985) -> Result<(), FormatError> {
1986    match record.kind.as_str() {
1987        CAPTURE_REPORT_KIND if record.stored_size > MAX_LOCAL_PAX_PAYLOAD as u64 => {
1988            return Err(FormatError::ReaderResourceLimitExceeded {
1989                field: "capture report payload bytes",
1990                cap: MAX_LOCAL_PAX_PAYLOAD as u64,
1991                actual: record.stored_size,
1992            });
1993        }
1994        "windows.security-descriptor" => {
1995            let security_information = parse_fixed_hex_u32(
1996                record
1997                    .meta
1998                    .get("TZAP.aux.meta.security-information")
1999                    .ok_or(FormatError::InvalidArchive(
2000                        "security-information mask is missing",
2001                    ))?,
2002                8,
2003                "security-information mask",
2004            )?;
2005            validate_self_relative_security_descriptor(
2006                retained.ok_or(FormatError::InvalidArchive(
2007                    "security descriptor payload was not retained",
2008                ))?,
2009                security_information,
2010            )?;
2011        }
2012        "windows.reparse-data" => {
2013            let expected_tag = parse_fixed_hex_u32(
2014                record
2015                    .meta
2016                    .get("TZAP.aux.meta.reparse-tag")
2017                    .ok_or(FormatError::InvalidArchive("reparse tag is missing"))?,
2018                8,
2019                "reparse tag",
2020            )?;
2021            validate_reparse_buffer(
2022                retained.ok_or(FormatError::InvalidArchive(
2023                    "reparse payload was not retained",
2024                ))?,
2025                expected_tag,
2026            )?;
2027        }
2028        "windows.ea-data" => validate_windows_ea_stream(
2029            retained.ok_or(FormatError::InvalidArchive("EA payload was not retained"))?,
2030        )?,
2031        "windows.object-id" if retained.map_or(0, <[u8]>::len) != 64 => {
2032            return invalid(
2033                "AuxiliaryMetadata",
2034                "Windows object ID payload is not 64 bytes",
2035            );
2036        }
2037        "macos.finder-info" if retained.map_or(0, <[u8]>::len) != 32 => {
2038            return invalid("AuxiliaryMetadata", "FinderInfo payload is not 32 bytes");
2039        }
2040        "macos.acl-native" => validate_darwin_acl_external(retained.ok_or(
2041            FormatError::InvalidArchive("macOS ACL payload was not retained"),
2042        )?)?,
2043        _ => {}
2044    }
2045    Ok(())
2046}
2047
2048fn validate_windows_stream_name(name: &[u8]) -> Result<(), FormatError> {
2049    if name.len() % 2 != 0 {
2050        return invalid(
2051            "AuxiliaryMetadata",
2052            "Windows alternate-data name is not UTF-16LE",
2053        );
2054    }
2055    let units = name
2056        .chunks_exact(2)
2057        .map(|unit| u16::from_le_bytes([unit[0], unit[1]]))
2058        .collect::<Vec<_>>();
2059    const SUFFIX: &[u16] = &[
2060        b':' as u16,
2061        b'$' as u16,
2062        b'D' as u16,
2063        b'A' as u16,
2064        b'T' as u16,
2065        b'A' as u16,
2066    ];
2067    if units.len() <= SUFFIX.len() + 1
2068        || units.first() != Some(&(b':' as u16))
2069        || !units.ends_with(SUFFIX)
2070        || units[1..units.len() - SUFFIX.len()]
2071            .iter()
2072            .any(|unit| matches!(*unit, 0 | 0x2f | 0x3a | 0x5c))
2073    {
2074        return invalid(
2075            "AuxiliaryMetadata",
2076            "Windows alternate-data name is not canonical :name:$DATA",
2077        );
2078    }
2079    Ok(())
2080}
2081
2082fn validate_self_relative_security_descriptor(
2083    payload: &[u8],
2084    security_information: u32,
2085) -> Result<(), FormatError> {
2086    if payload.len() < 20 || payload[0] != 1 {
2087        return invalid(
2088            "SecurityDescriptor",
2089            "invalid self-relative descriptor header",
2090        );
2091    }
2092    let control = u16::from_le_bytes([payload[2], payload[3]]);
2093    if control & 0x8000 == 0 {
2094        return invalid("SecurityDescriptor", "descriptor is not self-relative");
2095    }
2096    let owner = read_le_u32(payload, 4)?;
2097    let group = read_le_u32(payload, 8)?;
2098    let sacl = read_le_u32(payload, 12)?;
2099    let dacl = read_le_u32(payload, 16)?;
2100    const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
2101    const GROUP_SECURITY_INFORMATION: u32 = 0x0000_0002;
2102    const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
2103    const SACL_SECURITY_INFORMATION: u32 = 0x0000_0008;
2104    const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
2105    const PROTECTED_SACL_SECURITY_INFORMATION: u32 = 0x4000_0000;
2106    const UNPROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x2000_0000;
2107    const UNPROTECTED_SACL_SECURITY_INFORMATION: u32 = 0x1000_0000;
2108    const ALLOWED_SECURITY_INFORMATION: u32 = OWNER_SECURITY_INFORMATION
2109        | GROUP_SECURITY_INFORMATION
2110        | DACL_SECURITY_INFORMATION
2111        | SACL_SECURITY_INFORMATION
2112        | PROTECTED_DACL_SECURITY_INFORMATION
2113        | PROTECTED_SACL_SECURITY_INFORMATION
2114        | UNPROTECTED_DACL_SECURITY_INFORMATION
2115        | UNPROTECTED_SACL_SECURITY_INFORMATION;
2116    let dacl_protection = security_information
2117        & (PROTECTED_DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION);
2118    let expected_dacl_protection = if control & 0x0004 == 0 {
2119        0
2120    } else if control & 0x1000 != 0 {
2121        PROTECTED_DACL_SECURITY_INFORMATION
2122    } else {
2123        UNPROTECTED_DACL_SECURITY_INFORMATION
2124    };
2125    let sacl_protection = security_information
2126        & (PROTECTED_SACL_SECURITY_INFORMATION | UNPROTECTED_SACL_SECURITY_INFORMATION);
2127    let expected_sacl_protection = if control & 0x0010 == 0 {
2128        0
2129    } else if control & 0x2000 != 0 {
2130        PROTECTED_SACL_SECURITY_INFORMATION
2131    } else {
2132        UNPROTECTED_SACL_SECURITY_INFORMATION
2133    };
2134    if security_information == 0
2135        || security_information & !ALLOWED_SECURITY_INFORMATION != 0
2136        || (security_information & OWNER_SECURITY_INFORMATION != 0) != (owner != 0)
2137        || (security_information & GROUP_SECURITY_INFORMATION != 0) != (group != 0)
2138        || (security_information & DACL_SECURITY_INFORMATION != 0) != (control & 0x0004 != 0)
2139        || (security_information & SACL_SECURITY_INFORMATION != 0) != (control & 0x0010 != 0)
2140        || (dacl_protection != 0 && dacl_protection != expected_dacl_protection)
2141        || (sacl_protection != 0 && sacl_protection != expected_sacl_protection)
2142    {
2143        return invalid(
2144            "SecurityDescriptor",
2145            "security-information mask disagrees with captured descriptor components",
2146        );
2147    }
2148    for offset in [owner, group, sacl, dacl] {
2149        if offset != 0 && (offset < 20 || offset % 4 != 0 || offset as usize >= payload.len()) {
2150            return invalid(
2151                "SecurityDescriptor",
2152                "descriptor component offset is out of bounds",
2153            );
2154        }
2155    }
2156    for offset in [owner, group] {
2157        if offset != 0 {
2158            validate_sid(payload, offset as usize)?;
2159        }
2160    }
2161    if sacl != 0 {
2162        if control & 0x0010 == 0 {
2163            return invalid(
2164                "SecurityDescriptor",
2165                "SACL offset is present without SACL control bit",
2166            );
2167        }
2168        validate_acl(payload, sacl as usize)?;
2169    }
2170    if dacl != 0 {
2171        if control & 0x0004 == 0 {
2172            return invalid(
2173                "SecurityDescriptor",
2174                "DACL offset is present without DACL control bit",
2175            );
2176        }
2177        validate_acl(payload, dacl as usize)?;
2178    }
2179    Ok(())
2180}
2181
2182fn validate_sid(payload: &[u8], offset: usize) -> Result<(), FormatError> {
2183    let header = payload
2184        .get(offset..offset + 8)
2185        .ok_or(FormatError::InvalidArchive("SID header is out of bounds"))?;
2186    if header[0] != 1 || header[1] > 15 {
2187        return invalid("SecurityDescriptor", "SID header is invalid");
2188    }
2189    let len = 8usize
2190        .checked_add(header[1] as usize * 4)
2191        .ok_or(FormatError::InvalidArchive("SID size overflow"))?;
2192    if offset
2193        .checked_add(len)
2194        .is_none_or(|end| end > payload.len())
2195    {
2196        return invalid("SecurityDescriptor", "SID is out of bounds");
2197    }
2198    Ok(())
2199}
2200
2201fn validate_acl(payload: &[u8], offset: usize) -> Result<(), FormatError> {
2202    let header = payload
2203        .get(offset..offset + 8)
2204        .ok_or(FormatError::InvalidArchive("ACL header is out of bounds"))?;
2205    if !matches!(header[0], 2 | 4) {
2206        return invalid("SecurityDescriptor", "ACL revision is unsupported");
2207    }
2208    let size = u16::from_le_bytes([header[2], header[3]]) as usize;
2209    let count = u16::from_le_bytes([header[4], header[5]]) as usize;
2210    let end = offset
2211        .checked_add(size)
2212        .ok_or(FormatError::InvalidArchive("ACL size overflow"))?;
2213    if size < 8 || end > payload.len() {
2214        return invalid("SecurityDescriptor", "ACL size is out of bounds");
2215    }
2216    let mut cursor = offset + 8;
2217    for _ in 0..count {
2218        let ace = payload
2219            .get(cursor..cursor + 4)
2220            .ok_or(FormatError::InvalidArchive("ACE header is out of bounds"))?;
2221        let ace_size = u16::from_le_bytes([ace[2], ace[3]]) as usize;
2222        if ace_size < 4 || ace_size % 4 != 0 {
2223            return invalid("SecurityDescriptor", "ACE size is invalid");
2224        }
2225        cursor = cursor
2226            .checked_add(ace_size)
2227            .ok_or(FormatError::InvalidArchive("ACE size overflow"))?;
2228        if cursor > end {
2229            return invalid("SecurityDescriptor", "ACE exceeds ACL bounds");
2230        }
2231    }
2232    Ok(())
2233}
2234
2235fn validate_reparse_buffer(payload: &[u8], expected_tag: u32) -> Result<(), FormatError> {
2236    if payload.len() < 8 {
2237        return invalid("ReparseBuffer", "reparse buffer header is truncated");
2238    }
2239    let tag = read_le_u32(payload, 0)?;
2240    let data_len = u16::from_le_bytes([payload[4], payload[5]]) as usize;
2241    let header_len: usize = if tag & 0x8000_0000 == 0 { 24 } else { 8 };
2242    if tag != expected_tag
2243        || payload.len()
2244            != header_len
2245                .checked_add(data_len)
2246                .ok_or(FormatError::InvalidArchive("reparse buffer size overflow"))?
2247    {
2248        return invalid("ReparseBuffer", "reparse tag or length is inconsistent");
2249    }
2250    let data = &payload[header_len..];
2251    match tag {
2252        0xa000_000c => validate_reparse_name_offsets(data, 12)?,
2253        0xa000_0003 => validate_reparse_name_offsets(data, 8)?,
2254        _ => {}
2255    }
2256    Ok(())
2257}
2258
2259fn validate_reparse_name_offsets(
2260    data: &[u8],
2261    path_buffer_offset: usize,
2262) -> Result<(), FormatError> {
2263    if data.len() < path_buffer_offset {
2264        return invalid("ReparseBuffer", "reparse name header is truncated");
2265    }
2266    for field in [0usize, 4] {
2267        let offset = u16::from_le_bytes([data[field], data[field + 1]]) as usize;
2268        let len = u16::from_le_bytes([data[field + 2], data[field + 3]]) as usize;
2269        if offset % 2 != 0
2270            || len % 2 != 0
2271            || path_buffer_offset
2272                .checked_add(offset)
2273                .and_then(|start| start.checked_add(len))
2274                .is_none_or(|end| end > data.len())
2275        {
2276            return invalid("ReparseBuffer", "reparse name offset is out of bounds");
2277        }
2278    }
2279    Ok(())
2280}
2281
2282fn validate_windows_ea_stream(payload: &[u8]) -> Result<(), FormatError> {
2283    let mut cursor = 0usize;
2284    while cursor < payload.len() {
2285        let header_end = cursor
2286            .checked_add(8)
2287            .ok_or(FormatError::InvalidArchive("EA record offset overflow"))?;
2288        let header = payload
2289            .get(cursor..header_end)
2290            .ok_or(FormatError::InvalidArchive("EA record header is truncated"))?;
2291        let next = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
2292        let name_len = header[5] as usize;
2293        let value_len = u16::from_le_bytes([header[6], header[7]]) as usize;
2294        let record_len = 8usize
2295            .checked_add(name_len)
2296            .and_then(|value| value.checked_add(1))
2297            .and_then(|value| value.checked_add(value_len))
2298            .ok_or(FormatError::InvalidArchive("EA record size overflow"))?;
2299        let record_end = cursor
2300            .checked_add(record_len)
2301            .ok_or(FormatError::InvalidArchive("EA record end overflow"))?;
2302        let name_end = header_end
2303            .checked_add(name_len)
2304            .ok_or(FormatError::InvalidArchive("EA name end overflow"))?;
2305        if name_len == 0
2306            || record_end > payload.len()
2307            || payload.get(name_end) != Some(&0)
2308            || payload
2309                .get(header_end..name_end)
2310                .is_none_or(|name| name.contains(&0))
2311        {
2312            return invalid("WindowsEaStream", "EA record is malformed");
2313        }
2314        if next == 0 {
2315            if record_end != payload.len() {
2316                return invalid("WindowsEaStream", "EA stream has trailing bytes");
2317            }
2318            return Ok(());
2319        }
2320        let next_cursor = cursor
2321            .checked_add(next)
2322            .ok_or(FormatError::InvalidArchive("EA next-entry offset overflow"))?;
2323        if next < record_len || next % 4 != 0 || next_cursor > payload.len() {
2324            return invalid("WindowsEaStream", "EA next-entry offset is invalid");
2325        }
2326        if payload[record_end..next_cursor]
2327            .iter()
2328            .any(|byte| *byte != 0)
2329        {
2330            return invalid("WindowsEaStream", "EA alignment padding is non-zero");
2331        }
2332        cursor = next_cursor;
2333    }
2334    if payload.is_empty() {
2335        return invalid("WindowsEaStream", "EA stream is empty");
2336    }
2337    Ok(())
2338}
2339
2340fn read_le_u32(payload: &[u8], offset: usize) -> Result<u32, FormatError> {
2341    let bytes: [u8; 4] = payload
2342        .get(offset..offset + 4)
2343        .ok_or(FormatError::InvalidArchive(
2344            "little-endian u32 is out of bounds",
2345        ))?
2346        .try_into()
2347        .unwrap();
2348    Ok(u32::from_le_bytes(bytes))
2349}
2350
2351fn decode_auxiliary_name(encoding: &str, value: &[u8]) -> Result<Vec<u8>, FormatError> {
2352    match encoding {
2353        "none" if value.is_empty() => Ok(Vec::new()),
2354        "none" => invalid("AuxiliaryMetadata", "name is non-empty for none encoding"),
2355        "utf8" => {
2356            let text = std::str::from_utf8(value)
2357                .map_err(|_| FormatError::InvalidArchive("auxiliary UTF-8 name is invalid"))?;
2358            if text.is_empty() || text.nfc().collect::<String>() != text {
2359                return invalid(
2360                    "AuxiliaryMetadata",
2361                    "auxiliary UTF-8 name is empty or non-NFC",
2362                );
2363            }
2364            Ok(value.to_vec())
2365        }
2366        "bytes-base64" => {
2367            let decoded = canonical_base64_decode(value)?;
2368            if decoded.is_empty() {
2369                return invalid("AuxiliaryMetadata", "decoded auxiliary name is empty");
2370            }
2371            Ok(decoded)
2372        }
2373        "utf16le-base64" => {
2374            let decoded = canonical_base64_decode(value)?;
2375            if decoded.is_empty()
2376                || decoded.len() % 2 != 0
2377                || decoded.chunks_exact(2).any(|unit| unit == [0, 0])
2378            {
2379                return invalid("AuxiliaryMetadata", "decoded UTF-16LE name is invalid");
2380            }
2381            Ok(decoded)
2382        }
2383        _ => invalid("AuxiliaryMetadata", "unknown auxiliary name encoding"),
2384    }
2385}
2386
2387pub fn canonical_base64_decode(value: &[u8]) -> Result<Vec<u8>, FormatError> {
2388    if value.contains(&b'=') || value.iter().any(|byte| base64_value(*byte).is_none()) {
2389        return invalid("Base64", "encoding is not unpadded RFC 4648 base64");
2390    }
2391    if value.len() % 4 == 1 {
2392        return invalid("Base64", "invalid encoded length");
2393    }
2394    let mut out = Vec::with_capacity(value.len() / 4 * 3 + 2);
2395    let mut cursor = 0usize;
2396    while cursor + 4 <= value.len() {
2397        let a = base64_value(value[cursor]).unwrap();
2398        let b = base64_value(value[cursor + 1]).unwrap();
2399        let c = base64_value(value[cursor + 2]).unwrap();
2400        let d = base64_value(value[cursor + 3]).unwrap();
2401        out.push((a << 2) | (b >> 4));
2402        out.push((b << 4) | (c >> 2));
2403        out.push((c << 6) | d);
2404        cursor += 4;
2405    }
2406    match value.len() - cursor {
2407        0 => {}
2408        2 => {
2409            let a = base64_value(value[cursor]).unwrap();
2410            let b = base64_value(value[cursor + 1]).unwrap();
2411            if b & 0x0f != 0 {
2412                return invalid("Base64", "non-zero unused bits");
2413            }
2414            out.push((a << 2) | (b >> 4));
2415        }
2416        3 => {
2417            let a = base64_value(value[cursor]).unwrap();
2418            let b = base64_value(value[cursor + 1]).unwrap();
2419            let c = base64_value(value[cursor + 2]).unwrap();
2420            if c & 0x03 != 0 {
2421                return invalid("Base64", "non-zero unused bits");
2422            }
2423            out.push((a << 2) | (b >> 4));
2424            out.push((b << 4) | (c >> 2));
2425        }
2426        _ => unreachable!(),
2427    }
2428    Ok(out)
2429}
2430
2431pub fn canonical_base64_encode(value: &[u8]) -> Vec<u8> {
2432    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2433    let mut out = Vec::with_capacity(value.len().div_ceil(3) * 4);
2434    let mut chunks = value.chunks_exact(3);
2435    for chunk in &mut chunks {
2436        out.push(ALPHABET[(chunk[0] >> 2) as usize]);
2437        out.push(ALPHABET[(((chunk[0] & 3) << 4) | (chunk[1] >> 4)) as usize]);
2438        out.push(ALPHABET[(((chunk[1] & 15) << 2) | (chunk[2] >> 6)) as usize]);
2439        out.push(ALPHABET[(chunk[2] & 63) as usize]);
2440    }
2441    let remainder = chunks.remainder();
2442    if let Some(&a) = remainder.first() {
2443        out.push(ALPHABET[(a >> 2) as usize]);
2444        if let Some(&b) = remainder.get(1) {
2445            out.push(ALPHABET[(((a & 3) << 4) | (b >> 4)) as usize]);
2446            out.push(ALPHABET[((b & 15) << 2) as usize]);
2447        } else {
2448            out.push(ALPHABET[((a & 3) << 4) as usize]);
2449        }
2450    }
2451    out
2452}
2453
2454fn base64_value(byte: u8) -> Option<u8> {
2455    match byte {
2456        b'A'..=b'Z' => Some(byte - b'A'),
2457        b'a'..=b'z' => Some(byte - b'a' + 26),
2458        b'0'..=b'9' => Some(byte - b'0' + 52),
2459        b'+' => Some(62),
2460        b'/' => Some(63),
2461        _ => None,
2462    }
2463}
2464
2465pub fn decode_percent_name(value: &[u8]) -> Result<Vec<u8>, FormatError> {
2466    let mut out = Vec::with_capacity(value.len());
2467    let mut cursor = 0usize;
2468    while cursor < value.len() {
2469        if value[cursor] == b'%' {
2470            if cursor + 2 >= value.len()
2471                || !is_upper_hex(&value[cursor + 1])
2472                || !is_upper_hex(&value[cursor + 2])
2473            {
2474                return invalid("XattrName", "invalid percent encoding");
2475            }
2476            let decoded = hex_nibble(value[cursor + 1]) * 16 + hex_nibble(value[cursor + 2]);
2477            if decoded == 0
2478                || ((0x21..=0x7e).contains(&decoded) && decoded != b'%' && decoded != b'=')
2479            {
2480                return invalid("XattrName", "percent encoding is non-canonical");
2481            }
2482            out.push(decoded);
2483            cursor += 3;
2484        } else {
2485            let byte = value[cursor];
2486            if !(0x21..=0x7e).contains(&byte) || byte == b'%' || byte == b'=' {
2487                return invalid("XattrName", "xattr name byte must be percent encoded");
2488            }
2489            out.push(byte);
2490            cursor += 1;
2491        }
2492    }
2493    Ok(out)
2494}
2495
2496pub fn encode_percent_name(value: &[u8]) -> Result<String, FormatError> {
2497    if value.is_empty() || value.contains(&0) {
2498        return invalid("XattrName", "xattr name is empty or contains NUL");
2499    }
2500    let mut out = String::with_capacity(value.len());
2501    for &byte in value {
2502        if (0x21..=0x7e).contains(&byte) && byte != b'%' && byte != b'=' {
2503            out.push(char::from(byte));
2504        } else {
2505            use std::fmt::Write as _;
2506            write!(&mut out, "%{byte:02X}").expect("writing to String cannot fail");
2507        }
2508    }
2509    Ok(out)
2510}
2511
2512/// Convert Linux's version-2 POSIX ACL xattr ABI into v45 canonical SCHILY
2513/// POSIX.1e text. Numeric qualifiers are retained without name-service lookup.
2514pub fn linux_posix_acl_xattr_to_schily(value: &[u8]) -> Result<Vec<u8>, FormatError> {
2515    if value.len() < 4 || (value.len() - 4) % 8 != 0 || value[..4] != 2u32.to_le_bytes() {
2516        return invalid("LinuxAcl", "invalid POSIX ACL xattr framing");
2517    }
2518    let mut entries = Vec::new();
2519    for entry in value[4..].chunks_exact(8) {
2520        let tag = u16::from_le_bytes([entry[0], entry[1]]);
2521        let permissions = u16::from_le_bytes([entry[2], entry[3]]);
2522        let id = u32::from_le_bytes([entry[4], entry[5], entry[6], entry[7]]);
2523        if permissions & !7 != 0 {
2524            return invalid("LinuxAcl", "invalid POSIX ACL permissions");
2525        }
2526        let perms = format!(
2527            "{}{}{}",
2528            if permissions & 4 != 0 { 'r' } else { '-' },
2529            if permissions & 2 != 0 { 'w' } else { '-' },
2530            if permissions & 1 != 0 { 'x' } else { '-' },
2531        );
2532        let (category, text) = match tag {
2533            0x01 if id == u32::MAX => (0, format!("user::{perms}")),
2534            0x04 if id == u32::MAX => (1, format!("group::{perms}")),
2535            0x20 if id == u32::MAX => (2, format!("other::{perms}")),
2536            0x02 if id != u32::MAX => (3, format!("user:{id}:{perms}")),
2537            0x08 if id != u32::MAX => (4, format!("group:{id}:{perms}")),
2538            0x10 if id == u32::MAX => (5, format!("mask::{perms}")),
2539            _ => return invalid("LinuxAcl", "invalid POSIX ACL tag or qualifier"),
2540        };
2541        let qualifier = if matches!(category, 3 | 4) { id } else { 0 };
2542        entries.push((category, qualifier, text));
2543    }
2544    entries.sort_by_key(|(category, qualifier, _)| (*category, *qualifier));
2545    let text = entries
2546        .into_iter()
2547        .map(|(_, _, text)| text)
2548        .collect::<Vec<_>>()
2549        .join(",")
2550        .into_bytes();
2551    validate_posix_acl_text(&text)?;
2552    Ok(text)
2553}
2554
2555/// Convert canonical v45 SCHILY POSIX.1e text to Linux's version-2 POSIX ACL
2556/// xattr ABI without consulting host user/group databases.
2557pub fn schily_posix_acl_to_linux_xattr(value: &[u8]) -> Result<Vec<u8>, FormatError> {
2558    validate_posix_acl_text(value)?;
2559    let text = std::str::from_utf8(value)
2560        .map_err(|_| FormatError::InvalidArchive("ACL text is not UTF-8"))?;
2561    let mut entries = Vec::new();
2562    for tuple in text.split(',') {
2563        let fields = tuple.split(':').collect::<Vec<_>>();
2564        let permissions = fields[2]
2565            .bytes()
2566            .enumerate()
2567            .fold(0u16, |bits, (index, byte)| {
2568                bits | if byte != b'-' { 4 >> index } else { 0 }
2569            });
2570        let numeric_id = |name: &str| {
2571            name.parse::<u32>().or_else(|_| {
2572                fields
2573                    .get(3)
2574                    .ok_or(())
2575                    .and_then(|id| id.parse::<u32>().map_err(|_| ()))
2576            })
2577        };
2578        let (rank, tag, id) = match (fields[0], fields[1]) {
2579            ("user", "") => (0, 0x01u16, u32::MAX),
2580            ("user", id) => (
2581                1,
2582                0x02,
2583                numeric_id(id).map_err(|_| {
2584                    FormatError::ReaderUnsupported(
2585                        "named ACL principals require numeric qualifiers",
2586                    )
2587                })?,
2588            ),
2589            ("group", "") => (2, 0x04, u32::MAX),
2590            ("group", id) => (
2591                3,
2592                0x08,
2593                numeric_id(id).map_err(|_| {
2594                    FormatError::ReaderUnsupported(
2595                        "named ACL principals require numeric qualifiers",
2596                    )
2597                })?,
2598            ),
2599            ("mask", "") => (4, 0x10, u32::MAX),
2600            ("other", "") => (5, 0x20, u32::MAX),
2601            _ => return invalid("LinuxAcl", "unsupported POSIX ACL tuple"),
2602        };
2603        entries.push((rank, id, tag, permissions));
2604    }
2605    entries.sort_by_key(|(rank, id, _, _)| (*rank, *id));
2606    let mut out = 2u32.to_le_bytes().to_vec();
2607    for (_, id, tag, permissions) in entries {
2608        out.extend_from_slice(&tag.to_le_bytes());
2609        out.extend_from_slice(&permissions.to_le_bytes());
2610        out.extend_from_slice(&id.to_le_bytes());
2611    }
2612    Ok(out)
2613}
2614
2615pub(crate) fn parse_timestamp(value: &[u8]) -> Result<(i64, u32), FormatError> {
2616    let text = std::str::from_utf8(value)
2617        .map_err(|_| FormatError::InvalidArchive("timestamp is not ASCII"))?;
2618    if text.is_empty() || text.starts_with('+') {
2619        return invalid("Timestamp", "timestamp is not canonical");
2620    }
2621    let (integer, fraction) = text
2622        .split_once('.')
2623        .map_or((text, None), |(a, b)| (a, Some(b)));
2624    if integer == "-0" {
2625        return invalid("Timestamp", "timestamp is not canonical");
2626    }
2627    let unsigned = integer.strip_prefix('-').unwrap_or(integer);
2628    if unsigned.is_empty()
2629        || !unsigned.bytes().all(|byte| byte.is_ascii_digit())
2630        || (unsigned.len() > 1 && unsigned.starts_with('0'))
2631    {
2632        return invalid("Timestamp", "timestamp integer is not canonical");
2633    }
2634    let seconds = integer
2635        .parse::<i64>()
2636        .map_err(|_| FormatError::InvalidArchive("timestamp seconds exceed i64"))?;
2637    let nanos = if let Some(fraction) = fraction {
2638        if fraction.is_empty()
2639            || fraction.len() > 9
2640            || !fraction.bytes().all(|byte| byte.is_ascii_digit())
2641            || fraction.ends_with('0')
2642        {
2643            return invalid("Timestamp", "timestamp fraction is not canonical");
2644        }
2645        let mut padded = fraction.to_owned();
2646        padded.extend(std::iter::repeat_n('0', 9 - fraction.len()));
2647        padded.parse::<u32>().unwrap()
2648    } else {
2649        0
2650    };
2651    Ok((seconds, nanos))
2652}
2653
2654fn parse_sparse_line(payload: &[u8], cursor: &mut usize) -> Result<u64, FormatError> {
2655    let relative = payload[*cursor..]
2656        .iter()
2657        .position(|byte| *byte == b'\n')
2658        .ok_or(FormatError::InvalidArchive("sparse map line is truncated"))?;
2659    let end = cursor
2660        .checked_add(relative)
2661        .ok_or(FormatError::InvalidArchive("sparse map overflow"))?;
2662    let value = parse_decimal_u64(&payload[*cursor..end], "sparse map value")?;
2663    *cursor = end + 1;
2664    Ok(value)
2665}
2666
2667fn required<'a>(records: &'a PaxRecords, key: &'static str) -> Result<&'a [u8], FormatError> {
2668    records
2669        .get(key)
2670        .map(Vec::as_slice)
2671        .ok_or(FormatError::InvalidArchive(
2672            "required revision-45 PAX key is missing",
2673        ))
2674}
2675
2676fn expect_value(
2677    records: &PaxRecords,
2678    key: &'static str,
2679    expected: &[u8],
2680    structure: &'static str,
2681) -> Result<(), FormatError> {
2682    if required(records, key)? != expected {
2683        return invalid(structure, "required PAX value mismatch");
2684    }
2685    Ok(())
2686}
2687
2688fn parse_decimal_u64(value: &[u8], field: &'static str) -> Result<u64, FormatError> {
2689    if value.is_empty()
2690        || !value.iter().all(u8::is_ascii_digit)
2691        || (value.len() > 1 && value[0] == b'0')
2692    {
2693        return Err(FormatError::InvalidMetadata {
2694            structure: field,
2695            reason: "value is not minimal unsigned decimal",
2696        });
2697    }
2698    std::str::from_utf8(value)
2699        .ok()
2700        .and_then(|text| text.parse().ok())
2701        .ok_or(FormatError::InvalidMetadata {
2702            structure: field,
2703            reason: "decimal value exceeds u64",
2704        })
2705}
2706
2707fn parse_decimal_usize(value: &[u8], field: &'static str) -> Result<usize, FormatError> {
2708    let parsed = parse_decimal_u64(value, field)?;
2709    usize::try_from(parsed).map_err(|_| FormatError::InvalidMetadata {
2710        structure: field,
2711        reason: "decimal value exceeds usize",
2712    })
2713}
2714
2715fn parse_fixed_hex_u32(
2716    value: &[u8],
2717    width: usize,
2718    field: &'static str,
2719) -> Result<u32, FormatError> {
2720    if value.len() != width || !value.iter().all(is_lower_hex) {
2721        return Err(FormatError::InvalidMetadata {
2722            structure: field,
2723            reason: "value is not fixed-width lowercase hexadecimal",
2724        });
2725    }
2726    u32::from_str_radix(std::str::from_utf8(value).unwrap(), 16).map_err(|_| {
2727        FormatError::InvalidMetadata {
2728            structure: field,
2729            reason: "hexadecimal value exceeds u32",
2730        }
2731    })
2732}
2733
2734fn parse_fixed_hex_u64(
2735    value: &[u8],
2736    width: usize,
2737    field: &'static str,
2738) -> Result<u64, FormatError> {
2739    if value.len() != width || !value.iter().all(is_lower_hex) {
2740        return Err(FormatError::InvalidMetadata {
2741            structure: field,
2742            reason: "value is not fixed-width lowercase hexadecimal",
2743        });
2744    }
2745    u64::from_str_radix(std::str::from_utf8(value).unwrap(), 16).map_err(|_| {
2746        FormatError::InvalidMetadata {
2747            structure: field,
2748            reason: "hexadecimal value exceeds u64",
2749        }
2750    })
2751}
2752
2753fn parse_fixed_hex_32(value: &[u8]) -> Result<[u8; 32], FormatError> {
2754    if value.len() != 64 || !value.iter().all(is_lower_hex) {
2755        return invalid("AuxiliaryMetadata", "SHA-256 is not lowercase hexadecimal");
2756    }
2757    let mut out = [0u8; 32];
2758    for (index, pair) in value.chunks_exact(2).enumerate() {
2759        out[index] = hex_nibble(pair[0]) * 16 + hex_nibble(pair[1]);
2760    }
2761    Ok(out)
2762}
2763
2764fn is_lower_hex(byte: &u8) -> bool {
2765    byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)
2766}
2767
2768fn is_upper_hex(byte: &u8) -> bool {
2769    byte.is_ascii_digit() || (b'A'..=b'F').contains(byte)
2770}
2771
2772fn hex_nibble(byte: u8) -> u8 {
2773    match byte {
2774        b'0'..=b'9' => byte - b'0',
2775        b'a'..=b'f' => byte - b'a' + 10,
2776        b'A'..=b'F' => byte - b'A' + 10,
2777        _ => 0,
2778    }
2779}
2780
2781fn ascii_string(value: &[u8], field: &'static str) -> Result<String, FormatError> {
2782    if !value.is_ascii() {
2783        return Err(FormatError::InvalidMetadata {
2784            structure: field,
2785            reason: "value is not ASCII",
2786        });
2787    }
2788    Ok(std::str::from_utf8(value).unwrap().to_owned())
2789}
2790
2791pub(crate) fn is_source_os(value: &str) -> bool {
2792    matches!(
2793        value,
2794        "linux"
2795            | "freebsd"
2796            | "netbsd"
2797            | "openbsd"
2798            | "solaris"
2799            | "macos"
2800            | "windows"
2801            | "other-unix"
2802            | "other"
2803    )
2804}
2805
2806pub(crate) fn valid_filesystem_token(value: &str) -> bool {
2807    value == "unknown"
2808        || (1..=32).contains(&value.len())
2809            && value.bytes().all(|byte| {
2810                byte.is_ascii_lowercase()
2811                    || byte.is_ascii_digit()
2812                    || matches!(byte, b'-' | b'.' | b'_')
2813            })
2814}
2815
2816fn valid_profile_token(value: &str) -> bool {
2817    (1..=MAX_PROFILE_ID_LEN).contains(&value.len())
2818        && value.bytes().all(|byte| {
2819            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.' | b'_')
2820        })
2821}
2822
2823fn is_valid_profile_id(value: &str) -> bool {
2824    if is_known_profile(value) {
2825        return true;
2826    }
2827    if !valid_profile_token(value) || !value.starts_with("x.") {
2828        return false;
2829    }
2830    let components: Vec<_> = value.split('.').collect();
2831    components.len() >= 4
2832        && components[0] == "x"
2833        && components[1..].iter().all(|component| {
2834            !component.is_empty()
2835                && component.as_bytes()[0].is_ascii_alphanumeric()
2836                && component.bytes().all(|byte| {
2837                    byte.is_ascii_lowercase()
2838                        || byte.is_ascii_digit()
2839                        || matches!(byte, b'-' | b'_')
2840                })
2841        })
2842}
2843
2844fn is_known_profile(value: &str) -> bool {
2845    matches!(
2846        value,
2847        PORTABLE_PROFILE | POSIX_PROFILE | LINUX_PROFILE | MACOS_PROFILE | WINDOWS_PROFILE
2848    )
2849}
2850
2851fn is_builtin_aux_kind(value: &str) -> bool {
2852    matches!(
2853        value,
2854        CAPTURE_REPORT_KIND
2855            | "windows.security-descriptor"
2856            | "windows.alternate-data"
2857            | "windows.ea-data"
2858            | "windows.reparse-data"
2859            | "windows.object-id"
2860            | "windows.property-data"
2861            | "windows.efs-raw"
2862            | "macos.resource-fork"
2863            | "macos.acl-native"
2864            | "macos.finder-info"
2865            | "generic.xattr"
2866            | "generic.named-fork"
2867    )
2868}
2869
2870fn is_native_primary_key(key: &String) -> bool {
2871    key.starts_with("TZAP.linux.")
2872        || key.starts_with("TZAP.macos.")
2873        || key.starts_with("TZAP.windows.")
2874        || key.starts_with("TZAP.posix.")
2875        || key.starts_with("LIBARCHIVE.")
2876        || key.starts_with("SCHILY.")
2877        || key == "TZAP.unix.ctime-observed"
2878}
2879
2880fn is_system_primary_key(key: &String) -> bool {
2881    key.starts_with("TZAP.posix.device-")
2882        || key == "TZAP.linux.whiteout"
2883        || key == "TZAP.linux.project-id"
2884        || key == "TZAP.windows.reparse-placeholder"
2885        || key == "TZAP.windows.directory-case-sensitive"
2886        || key.starts_with("LIBARCHIVE.xattr.security")
2887        || key.starts_with("LIBARCHIVE.xattr.trusted")
2888        || key.starts_with("LIBARCHIVE.xattr.system")
2889}
2890
2891fn has_no_change_flags(records: &PaxRecords) -> Result<bool, FormatError> {
2892    let linux = records
2893        .get("TZAP.linux.fsflags")
2894        .map(|value| parse_fixed_hex_u64(value, 16, "Linux file flags"))
2895        .transpose()?
2896        .is_some_and(|value| value & 0x30 != 0);
2897    let bsd = records
2898        .get("TZAP.bsd.st-flags")
2899        .map(|value| parse_fixed_hex_u64(value, 16, "BSD file flags"))
2900        .transpose()?
2901        .is_some_and(|value| value & 0x0006_0006 != 0);
2902    let macos = records
2903        .get("TZAP.macos.st-flags")
2904        .map(|value| parse_fixed_hex_u64(value, 16, "macOS file flags"))
2905        .transpose()?
2906        .is_some_and(|value| value & 0x009f_0086 != 0);
2907    let projected = records.get("SCHILY.fflags").is_some_and(|value| {
2908        value.split(|byte| *byte == b',').any(|token| {
2909            matches!(
2910                token,
2911                b"append" | b"immutable" | b"sappnd" | b"schg" | b"uappnd" | b"uchg"
2912            )
2913        })
2914    });
2915    Ok(linux || bsd || macos || projected)
2916}
2917
2918fn valid_percent_encoded_detail(value: &[u8]) -> bool {
2919    let mut cursor = 0usize;
2920    let mut decoded = Vec::with_capacity(value.len());
2921    while cursor < value.len() {
2922        if value[cursor].is_ascii_alphanumeric()
2923            || matches!(value[cursor], b'.' | b'_' | b'~' | b'-')
2924        {
2925            decoded.push(value[cursor]);
2926            cursor += 1;
2927        } else if value[cursor] == b'%'
2928            && cursor + 2 < value.len()
2929            && is_upper_hex(&value[cursor + 1])
2930            && is_upper_hex(&value[cursor + 2])
2931        {
2932            let byte = hex_nibble(value[cursor + 1]) * 16 + hex_nibble(value[cursor + 2]);
2933            if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'~' | b'-') {
2934                return false;
2935            }
2936            decoded.push(byte);
2937            cursor += 3;
2938        } else {
2939            return false;
2940        }
2941    }
2942    std::str::from_utf8(&decoded).is_ok()
2943}
2944
2945fn invalid<T>(structure: &'static str, reason: &'static str) -> Result<T, FormatError> {
2946    Err(FormatError::InvalidMetadata { structure, reason })
2947}
2948
2949#[cfg(test)]
2950mod tests {
2951    use super::*;
2952
2953    #[test]
2954    fn canonical_pax_round_trip_and_order_rejection() {
2955        let records = portable_primary_pax(b"file.txt", 0o644, "other", false).unwrap();
2956        let encoded = encode_canonical_pax(&records).unwrap();
2957        assert_eq!(parse_canonical_pax(&encoded).unwrap(), records);
2958
2959        let mut reversed = encoded
2960            .split_inclusive(|byte| *byte == b'\n')
2961            .map(<[u8]>::to_vec)
2962            .collect::<Vec<_>>();
2963        reversed.swap(0, 1);
2964        assert!(parse_canonical_pax(&reversed.concat()).is_err());
2965    }
2966
2967    #[test]
2968    fn timestamp_rejects_negative_zero_integer_component() {
2969        assert!(parse_timestamp(b"-0").is_err());
2970        assert!(parse_timestamp(b"-0.5").is_err());
2971        assert_eq!(parse_timestamp(b"-1.5").unwrap(), (-1, 500_000_000));
2972    }
2973
2974    #[test]
2975    fn sparse_map_requires_merged_bounded_extents() {
2976        let mut payload = b"2\n0\n2\n4\n2\n".to_vec();
2977        payload.resize(512, 0);
2978        payload.extend_from_slice(b"abcd");
2979        let sparse = parse_sparse_payload(&payload, 6).unwrap();
2980        assert_eq!(sparse.extents.len(), 2);
2981
2982        let mut adjacent = b"2\n0\n2\n2\n2\n".to_vec();
2983        adjacent.resize(512, 0);
2984        adjacent.extend_from_slice(b"abcd");
2985        assert!(parse_sparse_payload(&adjacent, 4).is_err());
2986    }
2987
2988    #[test]
2989    fn linux_posix_acl_binary_and_canonical_text_round_trip() {
2990        let text = b"user::rw-,group::r--,other::---,user:123:r--,mask::r--";
2991        let binary = schily_posix_acl_to_linux_xattr(text).unwrap();
2992        assert_eq!(&binary[..4], &2u32.to_le_bytes());
2993        assert_eq!(linux_posix_acl_xattr_to_schily(&binary).unwrap(), text);
2994    }
2995
2996    #[test]
2997    fn profile_dependencies_and_extension_namespace_are_enforced() {
2998        let mut records = portable_primary_pax(b"file.txt", 0o644, "linux", false).unwrap();
2999        records.insert(
3000            "TZAP.metadata.required-profiles".into(),
3001            b"linux-backup-v1,portable-v1".to_vec(),
3002        );
3003        assert!(parse_primary_metadata(&records).is_err());
3004
3005        records.insert(
3006            "TZAP.metadata.required-profiles".into(),
3007            b"linux-backup-v1,portable-v1,posix-backup-v1".to_vec(),
3008        );
3009        assert!(parse_primary_metadata(&records).is_ok());
3010    }
3011
3012    #[test]
3013    fn canonical_base64_and_percent_name_codecs_round_trip_binary_values() {
3014        for len in 0..=512usize {
3015            let value = (0..len)
3016                .map(|index| (index.wrapping_mul(73).wrapping_add(19) & 0xff) as u8)
3017                .collect::<Vec<_>>();
3018            let encoded = canonical_base64_encode(&value);
3019            assert_eq!(canonical_base64_decode(&encoded).unwrap(), value);
3020            assert!(!encoded.contains(&b'='));
3021        }
3022        assert!(canonical_base64_decode(b"AB").is_err());
3023        assert!(canonical_base64_decode(b"ABC").is_err());
3024
3025        let name = (1u8..=u8::MAX).collect::<Vec<_>>();
3026        let encoded = encode_percent_name(&name).unwrap();
3027        assert_eq!(decode_percent_name(encoded.as_bytes()).unwrap(), name);
3028        assert!(encode_percent_name(b"").is_err());
3029        assert!(encode_percent_name(b"nul\0name").is_err());
3030    }
3031
3032    #[test]
3033    fn xattr_profile_ownership_depends_on_declared_source_os() {
3034        let mut macos = portable_primary_pax(b"file.txt", 0o644, "macos", false).unwrap();
3035        macos.insert(
3036            "TZAP.metadata.required-profiles".into(),
3037            b"macos-backup-v1,portable-v1,posix-backup-v1".to_vec(),
3038        );
3039        macos.insert(
3040            "LIBARCHIVE.xattr.security.example".into(),
3041            canonical_base64_encode(b"value"),
3042        );
3043        let parsed = parse_primary_metadata(&macos).unwrap();
3044        assert!(parsed.requires_system_restore);
3045
3046        let mut linux = portable_primary_pax(b"file.txt", 0o644, "linux", false).unwrap();
3047        linux.insert(
3048            "TZAP.metadata.required-profiles".into(),
3049            b"portable-v1,posix-backup-v1".to_vec(),
3050        );
3051        linux.insert(
3052            "LIBARCHIVE.xattr.unknown.example".into(),
3053            canonical_base64_encode(b"value"),
3054        );
3055        assert!(parse_primary_metadata(&linux).is_err());
3056        linux.insert(
3057            "TZAP.metadata.required-profiles".into(),
3058            b"linux-backup-v1,portable-v1,posix-backup-v1".to_vec(),
3059        );
3060        let parsed = parse_primary_metadata(&linux).unwrap();
3061        assert!(parsed.requires_system_restore);
3062    }
3063
3064    #[test]
3065    fn macos_entitlement_and_superuser_flags_are_system_class() {
3066        for flags in [0x0000_0080u64, 0x0008_0000, 0x0010_0000, 0x0080_0000] {
3067            let mut records = PaxRecords::new();
3068            records.insert(
3069                "TZAP.macos.st-flags".into(),
3070                format!("{flags:016x}").into_bytes(),
3071            );
3072            assert!(has_no_change_flags(&records).unwrap());
3073        }
3074    }
3075
3076    #[test]
3077    fn generic_xattr_auxiliary_uses_the_declared_source_os_namespace_rules() {
3078        let mut records = portable_primary_pax(b"file.txt", 0o644, "macos", false).unwrap();
3079        records.insert(
3080            "TZAP.metadata.required-profiles".into(),
3081            b"macos-backup-v1,portable-v1,posix-backup-v1".to_vec(),
3082        );
3083        let primary = parse_primary_metadata(&records).unwrap();
3084        let mut auxiliary = AuxiliaryRecord {
3085            ordinal: 0,
3086            kind: "generic.xattr".into(),
3087            profile: "posix-backup-v1".into(),
3088            restore_class: RestoreClass::System,
3089            native: true,
3090            name_encoding: "bytes-base64".into(),
3091            decoded_name: b"security.example".to_vec(),
3092            flags: 0,
3093            logical_size: 0,
3094            stored_size: 0,
3095            sha256: [0; 32],
3096            meta: BTreeMap::new(),
3097            sparse_layout: None,
3098            capture_report_payload: None,
3099        };
3100        assert!(validate_group_metadata(&primary, &[auxiliary.clone()]).is_ok());
3101
3102        auxiliary.profile = "linux-backup-v1".into();
3103        assert!(validate_group_metadata(&primary, &[auxiliary]).is_err());
3104    }
3105
3106    #[test]
3107    fn capture_report_detail_must_decode_to_canonical_utf8() {
3108        let records = portable_primary_pax(b"file.txt", 0o644, "other", false).unwrap();
3109        let declaration = parse_primary_metadata(&records).unwrap().declaration;
3110        let invalid = b"tzap-capture-report-v1\nportable-v1\tdata\tio-error\t%C3%28\n";
3111        assert!(parse_capture_report(invalid, &declaration).is_err());
3112
3113        let valid = b"tzap-capture-report-v1\nportable-v1\tdata\tio-error\t%C3%A9\n";
3114        assert!(parse_capture_report(valid, &declaration).is_ok());
3115    }
3116
3117    #[test]
3118    fn textual_acl_requires_canonical_tuple_order_and_fixed_fields() {
3119        let mut records = portable_primary_pax(b"file.txt", 0o640, "linux", false).unwrap();
3120        records.insert(
3121            "TZAP.metadata.required-profiles".into(),
3122            b"portable-v1,posix-backup-v1".to_vec(),
3123        );
3124        records.insert("TZAP.acl.projection".into(), b"exact".to_vec());
3125        records.insert(
3126            "TZAP.acl.syntax".into(),
3127            b"schily-posix1e-extra-id-v1".to_vec(),
3128        );
3129        records.insert(
3130            "SCHILY.acl.access".into(),
3131            b"user::rw-,group::r--,other::---,user:1000:r--,mask::r--".to_vec(),
3132        );
3133        assert!(parse_primary_metadata(&records).is_ok());
3134
3135        records.insert(
3136            "SCHILY.acl.access".into(),
3137            b"user::rw-,user:1000:r--,group::r--,other::---,mask::r--".to_vec(),
3138        );
3139        assert!(parse_primary_metadata(&records).is_err());
3140    }
3141
3142    #[test]
3143    fn nfs4_acl_rejects_compact_permission_and_flag_fields() {
3144        let mut records = portable_primary_pax(b"file.txt", 0o640, "macos", false).unwrap();
3145        records.insert(
3146            "TZAP.metadata.required-profiles".into(),
3147            b"macos-backup-v1,portable-v1,posix-backup-v1".to_vec(),
3148        );
3149        records.insert("TZAP.acl.projection".into(), b"exact".to_vec());
3150        records.insert(
3151            "TZAP.acl.syntax".into(),
3152            b"schily-nfs4-full-extra-id-v1".to_vec(),
3153        );
3154        records.insert(
3155            "SCHILY.acl.ace".into(),
3156            b"owner@:rwx-----------:-------:allow".to_vec(),
3157        );
3158        assert!(parse_primary_metadata(&records).is_ok());
3159
3160        records.insert("SCHILY.acl.ace".into(), b"owner@:rwx:fd:allow".to_vec());
3161        assert!(parse_primary_metadata(&records).is_err());
3162    }
3163
3164    #[test]
3165    fn darwin_acl_external_form_rejects_malformed_or_unbounded_payloads() {
3166        let mut empty = vec![0u8; 40];
3167        empty[..4].copy_from_slice(&[0x01, 0x2c, 0xc1, 0x6d]);
3168
3169        let mut bad_magic = empty.clone();
3170        bad_magic[0] = 0;
3171        assert!(validate_darwin_acl_external(&bad_magic).is_err());
3172        assert!(validate_darwin_acl_external(&empty[..39]).is_err());
3173
3174        let mut oversized_count = empty;
3175        oversized_count[36..40].copy_from_slice(&129u32.to_be_bytes());
3176        assert!(validate_darwin_acl_external(&oversized_count).is_err());
3177    }
3178}