1use 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#[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
216pub 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 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#[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 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 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
2512pub 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
2555pub 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}