Skip to main content

tzap_core/
writer.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{Cursor, Read};
4use std::rc::Rc;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use sha2::{Digest, Sha256};
9use unicode_normalization::UnicodeNormalization;
10use uuid::Uuid;
11
12use crate::compression::{
13    compress_zstd_frame_with_dictionary_and_jobs, compress_zstd_frame_with_jobs,
14};
15use crate::crypto::{
16    aead_encrypt, build_aad, compute_integrity_tag, derive_nonce, HmacDomain, KdfParams, MasterKey,
17    Subkeys,
18};
19use crate::entry_metadata::{
20    canonical_base64_encode, encode_canonical_pax, is_source_os,
21    parse_auxiliary_declaration_for_writer, parse_auxiliary_record, parse_primary_metadata,
22    portable_primary_pax, valid_filesystem_token, validate_group_metadata, ArchiveTimestamp,
23    RestoreClass, SparseExtent, CAPTURE_PARTIAL, CAPTURE_REPORT_KIND, EXTENDED_METADATA_V1,
24    HAS_AUXILIARY_STREAMS, HAS_NATIVE_METADATA, HAS_SPARSE_EXTENTS, MAX_SPARSE_EXTENTS,
25    PORTABLE_PROFILE, REQUIRES_SYSTEM_RESTORE,
26};
27use crate::fec::encode_parity_gf16;
28use crate::format::{
29    root_auth_spec_id_for_revision, AeadAlgo, ArchiveWriteError, BlockKind, CompressionAlgo,
30    FecAlgo, FormatError, KdfAlgo, BLOCK_RECORD_FRAMING_LEN, BOOTSTRAP_SIDECAR_HEADER_LEN,
31    CRITICAL_RECOVERY_LOCATOR_LEN, CRYPTO_EXTENSION_HEADER_LEN, CRYPTO_HEADER_FIXED_LEN,
32    CRYPTO_HEADER_HMAC_LEN, FORMAT_VERSION, MANIFEST_FOOTER_LEN, READER_MAX_CMRA_PARITY_PCT,
33    READER_MAX_INDEX_ROOT_FEC_CLASS_SHARDS, READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN,
34    READER_MAX_ROOT_AUTH_FOOTER_LEN, READER_MAX_ROOT_AUTH_SIGNER_IDENTITY_LEN,
35    VOLUME_FORMAT_REV_45, VOLUME_HEADER_LEN, VOLUME_TRAILER_LEN,
36};
37use crate::metadata::{
38    hash_prefix, normalize_lookup_file_path, validate_file_path_bytes, DirectoryHintEntry,
39    DirectoryHintShardEntry, DirectoryHintTableHeader, EnvelopeEntry, FileEntry, FrameEntry,
40    IndexRoot, IndexRootHeader, IndexShardHeader, ShardEntry, DIRECTORY_HINT_ENTRY_LEN,
41    DIRECTORY_HINT_TABLE_LEN, ENVELOPE_ENTRY_LEN, FILE_ENTRY_LEN, FRAME_ENTRY_LEN,
42    INDEX_SHARD_HEADER_LEN,
43};
44use crate::padding::suffix_pad_for_aead;
45use crate::root_auth::{
46    archive_root_for_revision, critical_metadata_digest, data_block_merkle_leaf_hash_for_revision,
47    data_block_merkle_root_from_leaf_hashes_for_revision, fec_layout_digest_for_revision,
48    index_digest_for_revision, root_auth_descriptor_digest_for_revision, signer_identity_digest,
49    ArchiveRootInputs, CriticalMetadataDigestInputs, FecLayoutObjectRow,
50};
51use crate::wire::{
52    compute_key_wrap_table_digest, BlockRecord, BootstrapSidecarHeader, CriticalMetadataImage,
53    CriticalMetadataRecoveryHeader, CriticalMetadataRecoveryShard, CriticalRecoveryLocator,
54    CryptoHeader, CryptoHeaderFixed, KeyWrapTableV1, ManifestFooter, RecipientRecordV1,
55    RootAuthFooterV1, SerializedRegion, VolumeHeader, VolumeTrailer,
56};
57
58const TAR_BLOCK_LEN: usize = 512;
59const MAX_REED_SOLOMON_GF16_SHARDS: u64 = 65_535;
60const MIN_BLOCK_SIZE: u32 = 4096;
61const DEFAULT_BLOCK_SIZE: u32 = 64 * 1024;
62const DEFAULT_CHUNK_SIZE: u32 = 256 * 1024;
63const DEFAULT_ENVELOPE_TARGET_SIZE: u32 = 1024 * 1024;
64const DEFAULT_FEC_DATA_SHARDS: u16 = 224;
65const DEFAULT_FEC_PARITY_SHARDS: u16 = 1;
66const DEFAULT_INDEX_FEC_DATA_SHARDS: u16 = 16;
67const DEFAULT_INDEX_FEC_PARITY_SHARDS: u16 = 1;
68const MIN_INDEX_ROOT_FEC_DATA_SHARDS: u16 = 16;
69const DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS: u16 = MIN_INDEX_ROOT_FEC_DATA_SHARDS;
70const DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS: u16 = 1;
71const DEFAULT_STRIPE_WIDTH: u32 = 8;
72const DEFAULT_VOLUME_LOSS_TOLERANCE: u8 = 1;
73const DEFAULT_BIT_ROT_BUFFER_PCT: u8 = 5;
74const DEFAULT_FILES_PER_INDEX_SHARD: usize = 10_000;
75const DIRECTORY_HINT_REQUIRED_FILE_COUNT: usize = 100_000;
76const MAX_FILES_PER_INDEX_SHARD: usize = 1_000_000;
77const MAX_HASH_PREFIX_RUN_FILES: usize = 50_000;
78const DEFAULT_DIRECTORY_HINT_ENTRIES_PER_SHARD: usize = 10_000;
79const CMRA_SHARD_SIZE: usize = 512;
80
81#[derive(Clone, Default)]
82pub enum KeyWrapRecordSource {
83    #[default]
84    None,
85    Fixed(Vec<RecipientRecordV1>),
86    Callback(Arc<dyn Fn() -> Result<Vec<RecipientRecordV1>, FormatError> + Send + Sync>),
87}
88
89impl KeyWrapRecordSource {
90    pub fn fixed(records: Vec<RecipientRecordV1>) -> Self {
91        Self::Fixed(records)
92    }
93
94    pub fn callback<F>(callback: F) -> Self
95    where
96        F: Fn() -> Result<Vec<RecipientRecordV1>, FormatError> + Send + Sync + 'static,
97    {
98        Self::Callback(Arc::new(callback))
99    }
100
101    fn resolve(&self) -> Result<Option<Vec<RecipientRecordV1>>, FormatError> {
102        match self {
103            Self::None => Ok(None),
104            Self::Fixed(records) => Ok(Some(records.clone())),
105            Self::Callback(callback) => Ok(Some(callback()?)),
106        }
107    }
108}
109
110fn default_jobs() -> usize {
111    std::thread::available_parallelism()
112        .map(|jobs| jobs.get())
113        .unwrap_or(1)
114}
115
116fn volume_format_revision_for_options(_options: &WriterOptions, _kdf_params: &KdfParams) -> u16 {
117    // Writer is intentionally canonicalized to v45-only output.
118    VOLUME_FORMAT_REV_45
119}
120
121fn resolve_key_wrap_artifacts(
122    kdf_params: &KdfParams,
123    archive_uuid: &[u8; 16],
124    session_id: &[u8; 16],
125    key_wrap_records: Option<&KeyWrapRecordSource>,
126) -> Result<(KdfParams, Option<Vec<u8>>), FormatError> {
127    match kdf_params {
128        KdfParams::RecipientWrap {
129            key_wrap_table_length,
130            key_wrap_table_record_count,
131            key_wrap_table_version,
132            ..
133        } => {
134            if *key_wrap_table_version != 1 {
135                return Err(FormatError::InvalidKdfParams(
136                    "recipient-wrap table version must be 1",
137                ));
138            }
139            let Some(records) = key_wrap_records
140                .ok_or(FormatError::WriterUnsupported(
141                    "RecipientWrap requires key-wrap records",
142                ))?
143                .resolve()?
144            else {
145                return Err(FormatError::WriterUnsupported(
146                    "RecipientWrap requires key-wrap records",
147                ));
148            };
149            if records.is_empty() {
150                return Err(FormatError::WriterUnsupported(
151                    "RecipientWrap requires at least one recipient record",
152                ));
153            }
154            let mut recipient_records = Vec::with_capacity(records.len());
155            for record in records {
156                let mut prepared = record.clone();
157                let record_length = u32_len(prepared.to_bytes()?.len(), "RecipientWrap record")?;
158                prepared = prepared.with_record_length(record_length);
159                recipient_records.push(prepared);
160            }
161            let declared_record_count = u32_len(
162                recipient_records.len(),
163                "KeyWrapTableV1 recipient_record_count",
164            )?;
165            if *key_wrap_table_record_count != 0
166                && *key_wrap_table_record_count != declared_record_count
167            {
168                return Err(FormatError::InvalidKdfParams(
169                    "recipient-wrap key_wrap_table_record_count mismatch",
170                ));
171            }
172
173            let key_wrap_table = KeyWrapTableV1 {
174                version: *key_wrap_table_version,
175                volume_format_rev: VOLUME_FORMAT_REV_45,
176                table_length: 0,
177                flags: 0,
178                archive_uuid: *archive_uuid,
179                session_id: *session_id,
180                recipient_record_count: declared_record_count,
181                records_offset: 96,
182                records_length: 0,
183                recipient_records,
184            }
185            .to_bytes()?;
186            let computed_key_wrap_table_length =
187                u32_len(key_wrap_table.len(), "KeyWrapTableV1 table_length")?;
188            if *key_wrap_table_length != 0
189                && computed_key_wrap_table_length != *key_wrap_table_length
190            {
191                return Err(FormatError::InvalidKdfParams(
192                    "recipient-wrap key_wrap_table_length mismatch",
193                ));
194            }
195            let key_wrap_table_digest =
196                compute_key_wrap_table_digest(computed_key_wrap_table_length, &key_wrap_table);
197            Ok((
198                KdfParams::RecipientWrap {
199                    key_wrap_table_length: computed_key_wrap_table_length,
200                    key_wrap_table_record_count: declared_record_count,
201                    key_wrap_table_version: *key_wrap_table_version,
202                    key_wrap_table_digest,
203                },
204                Some(key_wrap_table),
205            ))
206        }
207        _ => Ok((kdf_params.clone(), None)),
208    }
209}
210
211fn recipient_wrap_kdf_params_for_record_count(
212    record_count: usize,
213) -> Result<KdfParams, FormatError> {
214    Ok(KdfParams::RecipientWrap {
215        key_wrap_table_length: 0,
216        key_wrap_table_record_count: u32_len(
217            record_count,
218            "KeyWrapTableV1 recipient_record_count",
219        )?,
220        key_wrap_table_version: 1,
221        key_wrap_table_digest: [0u8; 32],
222    })
223}
224
225fn stabilized_key_wrap_record_source(
226    kdf_params: &KdfParams,
227    key_wrap_records: Option<&KeyWrapRecordSource>,
228) -> Result<Option<KeyWrapRecordSource>, FormatError> {
229    if !matches!(kdf_params, KdfParams::RecipientWrap { .. }) {
230        return Ok(None);
231    }
232    let Some(records) = key_wrap_records
233        .ok_or(FormatError::WriterUnsupported(
234            "RecipientWrap requires key-wrap records",
235        ))?
236        .resolve()?
237    else {
238        return Err(FormatError::WriterUnsupported(
239            "RecipientWrap requires key-wrap records",
240        ));
241    };
242    Ok(Some(KeyWrapRecordSource::fixed(records)))
243}
244
245fn should_emit_directory_hints(file_count: usize) -> bool {
246    file_count > DIRECTORY_HINT_REQUIRED_FILE_COUNT
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct WriterOptions {
251    pub block_size: u32,
252    pub chunk_size: u32,
253    pub envelope_target_size: u32,
254    pub stripe_width: u32,
255    pub volume_loss_tolerance: u8,
256    pub bit_rot_buffer_pct: u8,
257    pub zstd_level: i32,
258    pub jobs: usize,
259    pub aead_algo: AeadAlgo,
260    pub fec_data_shards: u16,
261    pub fec_parity_shards: u16,
262    pub index_fec_data_shards: u16,
263    pub index_fec_parity_shards: u16,
264    pub index_root_fec_data_shards: u16,
265    pub index_root_fec_parity_shards: u16,
266    pub max_path_length: u32,
267    pub target_volume_size: Option<u64>,
268    pub archive_uuid: Option<[u8; 16]>,
269    pub session_id: Option<[u8; 16]>,
270    pub closed_at_ns: i64,
271}
272
273impl Default for WriterOptions {
274    fn default() -> Self {
275        Self {
276            block_size: DEFAULT_BLOCK_SIZE,
277            chunk_size: DEFAULT_CHUNK_SIZE,
278            envelope_target_size: DEFAULT_ENVELOPE_TARGET_SIZE,
279            stripe_width: DEFAULT_STRIPE_WIDTH,
280            volume_loss_tolerance: DEFAULT_VOLUME_LOSS_TOLERANCE,
281            bit_rot_buffer_pct: DEFAULT_BIT_ROT_BUFFER_PCT,
282            zstd_level: 3,
283            jobs: default_jobs(),
284            aead_algo: AeadAlgo::AesGcmSiv256,
285            fec_data_shards: DEFAULT_FEC_DATA_SHARDS,
286            fec_parity_shards: DEFAULT_FEC_PARITY_SHARDS,
287            index_fec_data_shards: DEFAULT_INDEX_FEC_DATA_SHARDS,
288            index_fec_parity_shards: DEFAULT_INDEX_FEC_PARITY_SHARDS,
289            index_root_fec_data_shards: DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS,
290            index_root_fec_parity_shards: DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS,
291            max_path_length: 4096,
292            target_volume_size: None,
293            archive_uuid: None,
294            session_id: None,
295            closed_at_ns: 0,
296        }
297    }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub struct RootAuthWriterConfig<'a> {
302    pub authenticator_id: u16,
303    pub signer_identity_type: u16,
304    pub signer_identity: &'a [u8],
305    pub authenticator_value_length: u32,
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub struct RootAuthSigningRequest {
310    pub root_auth_spec_id: [u8; 24],
311    pub archive_uuid: [u8; 16],
312    pub session_id: [u8; 16],
313    pub archive_root: [u8; 32],
314}
315
316pub type RootAuthAuthenticator<'a> =
317    dyn FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError> + 'a;
318
319#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
320pub enum PortableModeOrigin {
321    Native,
322    #[default]
323    Projected,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct PortablePosixOwner {
328    pub uid: u64,
329    pub gid: u64,
330    pub uname: Option<String>,
331    pub gname: Option<String>,
332}
333
334/// Canonical v45 native metadata carried by the primary local-PAX record.
335///
336/// The writer validates the complete record set using the same parser as the
337/// reader. Callers supply only profile-owned native keys; portable identity,
338/// timestamps, paths, and the metadata declaration remain writer-owned.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum NativeAuxiliaryNameEncoding {
341    None,
342    Utf8,
343    Utf16Le,
344    Bytes,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct NativeAuxiliaryMetadata {
349    pub kind: String,
350    pub profile: String,
351    pub restore_class: RestoreClass,
352    pub native: bool,
353    pub name_encoding: NativeAuxiliaryNameEncoding,
354    /// Exact decoded name bytes. UTF-16 names are little-endian code units.
355    pub name: Vec<u8>,
356    pub flags: u64,
357    pub logical_size: u64,
358    pub payload: Vec<u8>,
359    pub meta: BTreeMap<String, Vec<u8>>,
360    streamed_payload: Option<StreamedAuxiliaryPayload>,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
364struct StreamedAuxiliaryPayload {
365    stored_size: u64,
366    sha256: [u8; 32],
367    sparse_extents: Option<Vec<SparseExtent>>,
368}
369
370impl NativeAuxiliaryMetadata {
371    pub fn new(
372        kind: impl Into<String>,
373        profile: impl Into<String>,
374        restore_class: RestoreClass,
375        payload: Vec<u8>,
376    ) -> Self {
377        let logical_size = payload.len() as u64;
378        Self {
379            kind: kind.into(),
380            profile: profile.into(),
381            restore_class,
382            native: true,
383            name_encoding: NativeAuxiliaryNameEncoding::None,
384            name: Vec::new(),
385            flags: 0,
386            logical_size,
387            payload,
388            meta: BTreeMap::new(),
389            streamed_payload: None,
390        }
391    }
392
393    /// Declares a re-openable auxiliary payload supplied by
394    /// [`RegularFileSource::open_auxiliary`].
395    pub fn new_streamed(
396        kind: impl Into<String>,
397        profile: impl Into<String>,
398        restore_class: RestoreClass,
399        stored_size: u64,
400        sha256: [u8; 32],
401    ) -> Self {
402        Self {
403            kind: kind.into(),
404            profile: profile.into(),
405            restore_class,
406            native: true,
407            name_encoding: NativeAuxiliaryNameEncoding::None,
408            name: Vec::new(),
409            flags: 0,
410            logical_size: stored_size,
411            payload: Vec::new(),
412            meta: BTreeMap::new(),
413            streamed_payload: Some(StreamedAuxiliaryPayload {
414                stored_size,
415                sha256,
416                sparse_extents: None,
417            }),
418        }
419    }
420
421    pub fn new_streamed_sparse(
422        kind: impl Into<String>,
423        profile: impl Into<String>,
424        restore_class: RestoreClass,
425        logical_size: u64,
426        sparse_extents: Vec<SparseExtent>,
427        sha256: [u8; 32],
428    ) -> Result<Self, FormatError> {
429        let map = encode_v45_sparse_map(&sparse_extents, logical_size)?;
430        let extent_bytes = sparse_extent_bytes(&sparse_extents, logical_size)?;
431        let stored_size = checked_u64_add(map.len() as u64, extent_bytes, "sparse auxiliary")?;
432        Ok(Self {
433            kind: kind.into(),
434            profile: profile.into(),
435            restore_class,
436            native: true,
437            name_encoding: NativeAuxiliaryNameEncoding::None,
438            name: Vec::new(),
439            flags: 1,
440            logical_size,
441            payload: Vec::new(),
442            meta: BTreeMap::new(),
443            streamed_payload: Some(StreamedAuxiliaryPayload {
444                stored_size,
445                sha256,
446                sparse_extents: Some(sparse_extents),
447            }),
448        })
449    }
450
451    pub fn is_streamed(&self) -> bool {
452        self.streamed_payload.is_some()
453    }
454
455    pub fn streamed_sparse_extents(&self) -> Option<&[SparseExtent]> {
456        self.streamed_payload
457            .as_ref()
458            .and_then(|payload| payload.sparse_extents.as_deref())
459    }
460
461    pub fn stored_payload_size(&self) -> u64 {
462        self.streamed_payload
463            .as_ref()
464            .map_or(self.payload.len() as u64, |payload| payload.stored_size)
465    }
466
467    fn sha256(&self) -> [u8; 32] {
468        self.streamed_payload.as_ref().map_or_else(
469            || Sha256::digest(&self.payload).into(),
470            |payload| payload.sha256,
471        )
472    }
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Default)]
476pub struct NativeFileMetadata {
477    pub required_profiles: Vec<String>,
478    pub optional_profiles: Vec<String>,
479    pub primary_pax_records: BTreeMap<String, Vec<u8>>,
480    pub auxiliary_records: Vec<NativeAuxiliaryMetadata>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct PortableFileMetadata {
485    pub source_os: String,
486    pub source_filesystem: String,
487    pub mode_origin: PortableModeOrigin,
488    pub posix_owner: Option<PortablePosixOwner>,
489    pub attributes: Option<u32>,
490    pub native: NativeFileMetadata,
491}
492
493/// Filesystem object kind emitted by a [`RegularFileSource`].
494///
495/// The trait predates revision-45 directory capture, so its historical name is
496/// retained for API compatibility. Implementations may now describe a regular
497/// file, explicit directory, or symbolic-link member.
498#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
499pub enum SourceEntryKind {
500    #[default]
501    Regular,
502    Directory,
503    Symlink,
504    Hardlink,
505    CharacterDevice,
506    BlockDevice,
507    Fifo,
508    /// Zero-data directory primary whose native metadata contains an exact,
509    /// validated Windows mount-point reparse payload.
510    ReparseDirectory,
511    /// Zero-data regular primary whose native metadata contains an exact opaque Windows reparse
512    /// payload that has no safe portable projection.
513    ReparseRegular,
514}
515
516impl Default for PortableFileMetadata {
517    fn default() -> Self {
518        Self {
519            source_os: "other".into(),
520            source_filesystem: "unknown".into(),
521            mode_origin: PortableModeOrigin::Projected,
522            posix_owner: None,
523            attributes: None,
524            native: NativeFileMetadata::default(),
525        }
526    }
527}
528
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct RegularFile<'a> {
531    pub path: &'a str,
532    pub contents: &'a [u8],
533    pub mode: u32,
534    pub mtime: ArchiveTimestamp,
535    pub portable_metadata: PortableFileMetadata,
536}
537
538impl<'a> RegularFile<'a> {
539    pub fn new(path: &'a str, contents: &'a [u8]) -> Self {
540        Self {
541            path,
542            contents,
543            mode: 0o644,
544            mtime: ArchiveTimestamp::UNIX_EPOCH,
545            portable_metadata: PortableFileMetadata::default(),
546        }
547    }
548}
549
550/// Re-openable source for one primary archive member.
551///
552/// The writer may replan when options such as target volume sizing need another
553/// pass, so implementations must return a fresh reader from each `open` call.
554/// Directory sources return an empty reader and a zero `file_data_size`.
555pub trait RegularFileSource {
556    fn archive_path(&self) -> &str;
557    fn entry_kind(&self) -> SourceEntryKind {
558        SourceEntryKind::Regular
559    }
560    /// Link target bytes for a symbolic-link or hardlink source.
561    fn link_target(&self) -> Option<&[u8]> {
562        None
563    }
564    fn file_data_size(&self) -> u64;
565    /// Canonical allocated extents for a sparse regular file.
566    ///
567    /// `file_data_size` remains the logical size. When this returns `Some`,
568    /// `open` must yield exactly the concatenated extent bytes in ascending map
569    /// order. `Some(&[])` represents an all-hole sparse file.
570    fn sparse_extents(&self) -> Option<&[SparseExtent]> {
571        None
572    }
573    fn mode(&self) -> u32;
574    fn mtime(&self) -> ArchiveTimestamp;
575    fn portable_metadata(&self) -> PortableFileMetadata {
576        PortableFileMetadata::default()
577    }
578    fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError>;
579    /// Opens a streamed auxiliary payload by its canonical ordinal.
580    ///
581    /// The default serves inline payloads. Sources using
582    /// [`NativeAuxiliaryMetadata::new_streamed`] must override this method and
583    /// return a fresh reader on every call.
584    fn open_auxiliary(&self, ordinal: usize) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
585        let metadata = self.portable_metadata();
586        let record =
587            metadata
588                .native
589                .auxiliary_records
590                .get(ordinal)
591                .ok_or(FormatError::WriterInvariant(
592                    "auxiliary source ordinal is missing",
593                ))?;
594        if record.is_streamed() {
595            return Err(FormatError::WriterUnsupported(
596                "streamed auxiliary source did not implement open_auxiliary",
597            )
598            .into());
599        }
600        Ok(Box::new(Cursor::new(record.payload.clone())))
601    }
602}
603
604impl RegularFileSource for RegularFile<'_> {
605    fn archive_path(&self) -> &str {
606        self.path
607    }
608
609    fn file_data_size(&self) -> u64 {
610        self.contents.len() as u64
611    }
612
613    fn mode(&self) -> u32 {
614        self.mode
615    }
616
617    fn mtime(&self) -> ArchiveTimestamp {
618        self.mtime
619    }
620
621    fn portable_metadata(&self) -> PortableFileMetadata {
622        self.portable_metadata.clone()
623    }
624
625    fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
626        Ok(Box::new(Cursor::new(self.contents)))
627    }
628}
629
630/// One observable phase of archive creation.
631#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
632pub enum ArchiveWritePhase {
633    /// Read and compress source payloads to produce a deterministic archive plan.
634    PlanningPayload,
635    /// Build index and other metadata plans after the payload layout is known.
636    PlanningMetadata,
637    /// Read, compress, protect, and write the planned payload blocks.
638    EmittingPayload,
639    /// Protect and write indexes, recovery metadata, footers, and trailers.
640    EmittingMetadata,
641}
642
643/// Receives phase-native progress while the archive writer consumes file data.
644///
645/// Multi-pass writers report source bytes separately for planning and emission.
646/// Automatic volume-size replanning may start `PlanningPayload` and
647/// `PlanningMetadata` more than once. Each call to
648/// [`ArchiveWriteProgressSink::phase_started`] begins a new phase occurrence;
649/// within that occurrence, one archive member reports at most its primary
650/// source bytes plus its declared streamed auxiliary payload bytes.
651pub trait ArchiveWriteProgressSink {
652    /// Reports that the writer entered a new creation phase.
653    fn phase_started(&mut self, phase: ArchiveWritePhase);
654
655    /// Reports source bytes consumed within one specific writer phase.
656    fn source_bytes_read(&mut self, phase: ArchiveWritePhase, archive_path: &str, bytes: u64);
657}
658
659pub(crate) struct SourceProgressState<'a> {
660    sink: &'a mut dyn ArchiveWriteProgressSink,
661    active_phase: Option<ArchiveWritePhase>,
662    phase_reported_by_path: BTreeMap<String, u64>,
663}
664
665impl<'a> SourceProgressState<'a> {
666    fn new(sink: &'a mut dyn ArchiveWriteProgressSink) -> Self {
667        Self {
668            sink,
669            active_phase: None,
670            phase_reported_by_path: BTreeMap::new(),
671        }
672    }
673
674    fn start_phase(&mut self, phase: ArchiveWritePhase) {
675        self.active_phase = Some(phase);
676        self.phase_reported_by_path.clear();
677        self.sink.phase_started(phase);
678    }
679
680    fn record(&mut self, archive_path: &str, bytes: u64, file_data_size: u64) {
681        if bytes == 0 || file_data_size == 0 {
682            return;
683        }
684        if let Some(phase) = self.active_phase {
685            let phase_reported = self
686                .phase_reported_by_path
687                .entry(archive_path.to_owned())
688                .or_default();
689            let capped_next = phase_reported.saturating_add(bytes).min(file_data_size);
690            let delta = capped_next.saturating_sub(*phase_reported);
691            if delta > 0 {
692                *phase_reported = capped_next;
693                self.sink.source_bytes_read(phase, archive_path, delta);
694            }
695        }
696    }
697}
698
699struct ProgressRegularFileSource<'a, S> {
700    inner: &'a S,
701    source_bytes: u64,
702    state: Rc<RefCell<SourceProgressState<'a>>>,
703}
704
705impl<S: RegularFileSource> RegularFileSource for ProgressRegularFileSource<'_, S> {
706    fn archive_path(&self) -> &str {
707        self.inner.archive_path()
708    }
709
710    fn entry_kind(&self) -> SourceEntryKind {
711        self.inner.entry_kind()
712    }
713
714    fn link_target(&self) -> Option<&[u8]> {
715        self.inner.link_target()
716    }
717
718    fn file_data_size(&self) -> u64 {
719        self.inner.file_data_size()
720    }
721
722    fn sparse_extents(&self) -> Option<&[SparseExtent]> {
723        self.inner.sparse_extents()
724    }
725
726    fn mode(&self) -> u32 {
727        self.inner.mode()
728    }
729
730    fn mtime(&self) -> ArchiveTimestamp {
731        self.inner.mtime()
732    }
733
734    fn portable_metadata(&self) -> PortableFileMetadata {
735        self.inner.portable_metadata()
736    }
737
738    fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
739        Ok(Box::new(SourceProgressReader {
740            inner: self.inner.open()?,
741            archive_path: self.inner.archive_path().to_owned(),
742            source_bytes: self.source_bytes,
743            state: Rc::clone(&self.state),
744        }))
745    }
746
747    fn open_auxiliary(&self, ordinal: usize) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
748        Ok(Box::new(SourceProgressReader {
749            inner: self.inner.open_auxiliary(ordinal)?,
750            archive_path: self.inner.archive_path().to_owned(),
751            source_bytes: self.source_bytes,
752            state: Rc::clone(&self.state),
753        }))
754    }
755}
756
757struct SourceProgressReader<'a> {
758    inner: Box<dyn Read + 'a>,
759    archive_path: String,
760    source_bytes: u64,
761    state: Rc<RefCell<SourceProgressState<'a>>>,
762}
763
764impl Read for SourceProgressReader<'_> {
765    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
766        let bytes = self.inner.read(buf)?;
767        self.state
768            .borrow_mut()
769            .record(&self.archive_path, bytes as u64, self.source_bytes);
770        Ok(bytes)
771    }
772}
773
774pub(crate) type SourceProgressHandle<'a> = Rc<RefCell<SourceProgressState<'a>>>;
775
776fn progress_sources<'a, S: RegularFileSource>(
777    files: &'a [S],
778    sink: &'a mut dyn ArchiveWriteProgressSink,
779) -> (
780    Vec<ProgressRegularFileSource<'a, S>>,
781    SourceProgressHandle<'a>,
782) {
783    let state = Rc::new(RefCell::new(SourceProgressState::new(sink)));
784    let sources = files
785        .iter()
786        .map(|inner| {
787            let source_bytes = inner
788                .portable_metadata()
789                .native
790                .auxiliary_records
791                .iter()
792                .filter(|record| record.is_streamed())
793                .fold(inner.file_data_size(), |total, record| {
794                    total.saturating_add(record.stored_payload_size())
795                });
796            ProgressRegularFileSource {
797                inner,
798                source_bytes,
799                state: Rc::clone(&state),
800            }
801        })
802        .collect();
803    (sources, state)
804}
805
806/// Streaming destination for archive volumes and optional bootstrap sidecar.
807///
808/// Calls arrive in archive order for each volume, but records are interleaved
809/// across volumes according to the archive stripe layout.
810pub trait ArchiveWriteSink {
811    fn begin_archive(&mut self, volume_count: usize) -> Result<(), ArchiveWriteError>;
812    fn write_volume(&mut self, volume_index: usize, bytes: &[u8]) -> Result<(), ArchiveWriteError>;
813    fn write_bootstrap_sidecar(&mut self, bytes: &[u8]) -> Result<(), ArchiveWriteError>;
814}
815
816#[derive(Debug, Clone, PartialEq, Eq)]
817pub struct WrittenArchiveSummary {
818    pub volume_count: usize,
819    pub archive_bytes: u64,
820    pub bootstrap_sidecar_bytes: u64,
821    pub archive_uuid: [u8; 16],
822    pub session_id: [u8; 16],
823    pub timings: WriterTimings,
824}
825
826#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
827pub struct WriterTimings {
828    pub total: Duration,
829    pub plan_payload: Duration,
830    pub plan_metadata: Duration,
831    pub emit_payload: Duration,
832    pub emit_metadata: Duration,
833}
834
835impl WriterTimings {
836    fn add_assign(&mut self, other: Self) {
837        self.total += other.total;
838        self.plan_payload += other.plan_payload;
839        self.plan_metadata += other.plan_metadata;
840        self.emit_payload += other.emit_payload;
841        self.emit_metadata += other.emit_metadata;
842    }
843}
844
845/// In-memory sink used by the compatibility writer APIs.
846#[derive(Debug, Clone, Default, PartialEq, Eq)]
847pub struct MemoryArchiveSink {
848    pub volumes: Vec<Vec<u8>>,
849    pub bootstrap_sidecar: Vec<u8>,
850}
851
852impl ArchiveWriteSink for MemoryArchiveSink {
853    fn begin_archive(&mut self, volume_count: usize) -> Result<(), ArchiveWriteError> {
854        self.volumes = vec![Vec::new(); volume_count];
855        self.bootstrap_sidecar.clear();
856        Ok(())
857    }
858
859    fn write_volume(&mut self, volume_index: usize, bytes: &[u8]) -> Result<(), ArchiveWriteError> {
860        let volume = self
861            .volumes
862            .get_mut(volume_index)
863            .ok_or(FormatError::WriterInvariant(
864                "volume sink index is out of bounds",
865            ))?;
866        volume.extend_from_slice(bytes);
867        Ok(())
868    }
869
870    fn write_bootstrap_sidecar(&mut self, bytes: &[u8]) -> Result<(), ArchiveWriteError> {
871        self.bootstrap_sidecar.extend_from_slice(bytes);
872        Ok(())
873    }
874}
875
876/// Completed archive artifacts produced by the compatibility writer APIs.
877///
878/// APIs returning this value build all volume bytes before returning. Use the
879/// sink writer when archive bytes should be delivered incrementally.
880#[derive(Debug, Clone, PartialEq, Eq)]
881pub struct WrittenArchive {
882    pub bytes: Vec<u8>,
883    pub volumes: Vec<Vec<u8>>,
884    pub bootstrap_sidecar: Vec<u8>,
885    pub archive_uuid: [u8; 16],
886    pub session_id: [u8; 16],
887    pub timings: WriterTimings,
888}
889
890#[derive(Debug, Clone)]
891struct TarMember {
892    path: Vec<u8>,
893    entry_kind: SourceEntryKind,
894    link_target: Option<Vec<u8>>,
895    tar_member_group_start: u64,
896    tar_member_group_size: u64,
897    file_data_size: u64,
898    sparse_extents: Option<Vec<SparseExtent>>,
899    mode: u32,
900    mtime: ArchiveTimestamp,
901    portable_metadata: PortableFileMetadata,
902}
903
904#[derive(Debug, Clone)]
905struct PayloadFrame {
906    frame_index: u64,
907    envelope_index: u64,
908    member_index: usize,
909    offset_in_envelope: u32,
910    compressed_size: u32,
911    decompressed_size: u32,
912    flags: u32,
913    tar_stream_offset: u64,
914}
915
916#[derive(Debug, Clone)]
917struct FileRow {
918    path_hash: [u8; 8],
919    path: Vec<u8>,
920    member_index: usize,
921    member: TarMember,
922}
923
924#[derive(Debug, Clone)]
925struct PlannedIndexShard {
926    shard_index: u64,
927    plaintext: Vec<u8>,
928    file_count: u32,
929    first_path_hash: [u8; 8],
930    last_path_hash: [u8; 8],
931}
932
933#[derive(Debug, Clone)]
934struct PlannedDirectoryHintShard {
935    hint_shard_index: u64,
936    plaintext: Vec<u8>,
937    entry_count: u64,
938    first_dir_hash: [u8; 8],
939    last_dir_hash: [u8; 8],
940}
941
942#[derive(Debug, Clone)]
943#[cfg(test)]
944struct PayloadEnvelope {
945    envelope_index: u64,
946    plaintext: Vec<u8>,
947}
948
949#[derive(Debug, Clone)]
950struct PayloadObject {
951    envelope_index: u64,
952    plaintext_size: u32,
953    object: ObjectExtent,
954}
955
956#[derive(Debug, Clone)]
957struct EncryptedObject {
958    first_block_index: u64,
959    data_block_count: u32,
960    parity_block_count: u32,
961    encrypted_size: u32,
962    records: Vec<BlockRecord>,
963}
964
965#[derive(Debug, Clone, Copy, PartialEq, Eq)]
966struct ObjectExtent {
967    first_block_index: u64,
968    data_block_count: u32,
969    parity_block_count: u32,
970    encrypted_size: u32,
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
974struct PlannedEncryptedObject {
975    data_block_count: u32,
976    parity_block_count: u32,
977    encrypted_size: u32,
978}
979
980#[derive(Debug, Clone, Copy, PartialEq, Eq)]
981enum MetadataObjectKind {
982    IndexRoot,
983    Dictionary,
984}
985
986impl MetadataObjectKind {
987    fn too_large_error(self) -> FormatError {
988        match self {
989            Self::IndexRoot => FormatError::WriterUnsupported("IndexRoot too large"),
990            Self::Dictionary => FormatError::WriterUnsupported("dictionary object too large"),
991        }
992    }
993}
994
995impl ObjectExtent {
996    fn new(first_block_index: u64, plan: PlannedEncryptedObject) -> Result<Self, FormatError> {
997        Ok(Self {
998            first_block_index,
999            data_block_count: plan.data_block_count,
1000            parity_block_count: plan.parity_block_count,
1001            encrypted_size: plan.encrypted_size,
1002        })
1003    }
1004
1005    fn next_block_index(self) -> Result<u64, FormatError> {
1006        checked_u64_add(
1007            self.first_block_index,
1008            self.data_block_count as u64 + self.parity_block_count as u64,
1009            "next_block_index",
1010        )
1011    }
1012}
1013
1014impl From<&EncryptedObject> for ObjectExtent {
1015    fn from(object: &EncryptedObject) -> Self {
1016        Self {
1017            first_block_index: object.first_block_index,
1018            data_block_count: object.data_block_count,
1019            parity_block_count: object.parity_block_count,
1020            encrypted_size: object.encrypted_size,
1021        }
1022    }
1023}
1024
1025#[derive(Debug, Clone)]
1026struct PlannedDirectoryHintObject {
1027    hint_shard_index: u64,
1028    compressed: Vec<u8>,
1029    extent: ObjectExtent,
1030}
1031
1032struct WriterPlan {
1033    options: WriterOptions,
1034    archive_uuid: [u8; 16],
1035    session_id: [u8; 16],
1036    crypto_header: Vec<u8>,
1037    tar_members: Vec<TarMember>,
1038    frames: Vec<PayloadFrame>,
1039    payload_objects: Vec<PayloadObject>,
1040    index_root_plaintext: Vec<u8>,
1041    compressed_index_root: Vec<u8>,
1042    index_root_extent: ObjectExtent,
1043    index_shard_objects: Vec<PlannedIndexShardObject>,
1044    shard_entries: Vec<ShardEntry>,
1045    volume_format_rev: u16,
1046    compressed_dictionary: Option<Vec<u8>>,
1047    dictionary_extent: Option<(ObjectExtent, u32)>,
1048    directory_hint_objects: Vec<PlannedDirectoryHintObject>,
1049    directory_hint_entries: Vec<DirectoryHintShardEntry>,
1050    root_auth_footer_length: Option<u32>,
1051    key_wrap_table: Option<Vec<u8>>,
1052    block_records_offset: u64,
1053    total_block_count: u64,
1054}
1055
1056struct PlannedIndexShardObject {
1057    shard_index: u64,
1058    compressed: Vec<u8>,
1059    extent: ObjectExtent,
1060}
1061
1062struct PayloadPlanning {
1063    tar_members: Vec<TarMember>,
1064    frames: Vec<PayloadFrame>,
1065    payload_objects: Vec<PayloadObject>,
1066    payload_block_count: u64,
1067    tar_total_size: u64,
1068    content_sha256: [u8; 32],
1069}
1070
1071struct PayloadEnvelopeBuilder {
1072    envelope_index: u64,
1073    plaintext: Vec<u8>,
1074}
1075
1076#[derive(Debug, Clone, Copy)]
1077struct PayloadFrameMetadataInput {
1078    frame_index: u64,
1079    envelope_index: u64,
1080    member_index: usize,
1081    offset_in_envelope: u32,
1082    compressed_size: usize,
1083    decompressed_size: usize,
1084    member_start: u64,
1085    member_offset: u64,
1086    member_group_size: u64,
1087}
1088
1089fn payload_envelope_needs_flush(
1090    envelope: &PayloadEnvelopeBuilder,
1091    frame_len: usize,
1092    options: WriterOptions,
1093) -> Result<bool, FormatError> {
1094    let next_len = checked_usize_add(envelope.plaintext.len(), frame_len, "payload")?;
1095    Ok(!envelope.plaintext.is_empty()
1096        && (next_len > options.envelope_target_size as usize
1097            || !payload_object_can_fit(next_len, options)?))
1098}
1099
1100fn payload_frame_metadata(input: PayloadFrameMetadataInput) -> Result<PayloadFrame, FormatError> {
1101    let mut flags = 0u32;
1102    if input.member_offset == 0 {
1103        flags |= 0x0000_0001;
1104    }
1105    if checked_u64_add(
1106        input.member_offset,
1107        input.decompressed_size as u64,
1108        "payload chunk",
1109    )? == input.member_group_size
1110    {
1111        flags |= 0x0000_0002;
1112    }
1113    Ok(PayloadFrame {
1114        frame_index: input.frame_index,
1115        envelope_index: input.envelope_index,
1116        member_index: input.member_index,
1117        offset_in_envelope: input.offset_in_envelope,
1118        compressed_size: u32_len(input.compressed_size, "FrameEntry.compressed_size")?,
1119        decompressed_size: u32_len(input.decompressed_size, "FrameEntry.decompressed_size")?,
1120        flags,
1121        tar_stream_offset: checked_u64_add(
1122            input.member_start,
1123            input.member_offset,
1124            "PayloadFrame.tar_stream_offset",
1125        )?,
1126    })
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq)]
1130pub(crate) struct StreamingRegularMember {
1131    pub archive_path: Vec<u8>,
1132    pub entry_kind: SourceEntryKind,
1133    pub link_target: Option<Vec<u8>>,
1134    pub file_data_size: u64,
1135    pub sparse_extents: Option<Vec<SparseExtent>>,
1136    pub mode: u32,
1137    pub mtime: ArchiveTimestamp,
1138    pub portable_metadata: PortableFileMetadata,
1139}
1140
1141struct WriterEmissionState {
1142    volume_headers: Vec<[u8; VOLUME_HEADER_LEN]>,
1143    bytes_written: Vec<u64>,
1144    record_counts: Vec<u64>,
1145    volume_format_rev: u16,
1146    data_leaf_hashes: Option<Vec<(u64, [u8; 32])>>,
1147    next_block_index: u64,
1148}
1149
1150pub(crate) struct StreamingArchiveWriter<'a, O: ArchiveWriteSink> {
1151    sink: &'a mut O,
1152    options: WriterOptions,
1153    archive_uuid: [u8; 16],
1154    session_id: [u8; 16],
1155    crypto_header: Vec<u8>,
1156    subkeys: Subkeys,
1157    tar_members: Vec<TarMember>,
1158    frames: Vec<PayloadFrame>,
1159    payload_objects: Vec<PayloadObject>,
1160    payload_block_count: u64,
1161    tar_total_size: u64,
1162    hasher: Sha256,
1163    next_frame_index: u64,
1164    envelope: PayloadEnvelopeBuilder,
1165    emission_state: WriterEmissionState,
1166}
1167
1168pub fn write_archive(
1169    files: &[RegularFile<'_>],
1170    master_key: &MasterKey,
1171    options: WriterOptions,
1172) -> Result<WrittenArchive, FormatError> {
1173    write_archive_inner(
1174        files,
1175        master_key,
1176        options,
1177        None,
1178        &KdfParams::Raw,
1179        None,
1180        None,
1181        None,
1182    )
1183}
1184
1185pub fn write_archive_unencrypted(
1186    files: &[RegularFile<'_>],
1187    mut options: WriterOptions,
1188) -> Result<WrittenArchive, FormatError> {
1189    options.aead_algo = AeadAlgo::None;
1190    let placeholder = MasterKey::from_raw_key(&[0; 32])?;
1191    write_archive_inner(
1192        files,
1193        &placeholder,
1194        options,
1195        None,
1196        &KdfParams::None,
1197        None,
1198        None,
1199        None,
1200    )
1201}
1202
1203pub fn write_archive_with_kdf(
1204    files: &[RegularFile<'_>],
1205    master_key: &MasterKey,
1206    options: WriterOptions,
1207    kdf_params: &KdfParams,
1208) -> Result<WrittenArchive, FormatError> {
1209    write_archive_inner(
1210        files, master_key, options, None, kdf_params, None, None, None,
1211    )
1212}
1213
1214pub fn write_archive_with_recipient_wrap_records(
1215    files: &[RegularFile<'_>],
1216    master_key: &MasterKey,
1217    options: WriterOptions,
1218    records: Vec<RecipientRecordV1>,
1219) -> Result<WrittenArchive, FormatError> {
1220    let kdf_params = recipient_wrap_kdf_params_for_record_count(records.len())?;
1221    let key_wrap_records = KeyWrapRecordSource::fixed(records);
1222    write_archive_inner(
1223        files,
1224        master_key,
1225        options,
1226        None,
1227        &kdf_params,
1228        None,
1229        None,
1230        Some(&key_wrap_records),
1231    )
1232}
1233
1234pub fn write_archive_with_root_auth_and_recipient_wrap_records<F>(
1235    files: &[RegularFile<'_>],
1236    master_key: &MasterKey,
1237    options: WriterOptions,
1238    records: Vec<RecipientRecordV1>,
1239    root_auth: RootAuthWriterConfig<'_>,
1240    mut authenticator: F,
1241) -> Result<WrittenArchive, FormatError>
1242where
1243    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
1244{
1245    let kdf_params = recipient_wrap_kdf_params_for_record_count(records.len())?;
1246    let key_wrap_records = KeyWrapRecordSource::fixed(records);
1247    write_archive_inner(
1248        files,
1249        master_key,
1250        options,
1251        None,
1252        &kdf_params,
1253        Some(root_auth),
1254        Some(&mut authenticator),
1255        Some(&key_wrap_records),
1256    )
1257}
1258
1259pub fn write_archive_with_root_auth<F>(
1260    files: &[RegularFile<'_>],
1261    master_key: &MasterKey,
1262    options: WriterOptions,
1263    root_auth: RootAuthWriterConfig<'_>,
1264    mut authenticator: F,
1265) -> Result<WrittenArchive, FormatError>
1266where
1267    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
1268{
1269    let kdf_params = if options.aead_algo.is_encrypted() {
1270        KdfParams::Raw
1271    } else {
1272        KdfParams::None
1273    };
1274    write_archive_inner(
1275        files,
1276        master_key,
1277        options,
1278        None,
1279        &kdf_params,
1280        Some(root_auth),
1281        Some(&mut authenticator),
1282        None,
1283    )
1284}
1285
1286pub fn write_archive_with_root_auth_and_kdf<F>(
1287    files: &[RegularFile<'_>],
1288    master_key: &MasterKey,
1289    options: WriterOptions,
1290    kdf_params: &KdfParams,
1291    root_auth: RootAuthWriterConfig<'_>,
1292    mut authenticator: F,
1293) -> Result<WrittenArchive, FormatError>
1294where
1295    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
1296{
1297    write_archive_inner(
1298        files,
1299        master_key,
1300        options,
1301        None,
1302        kdf_params,
1303        Some(root_auth),
1304        Some(&mut authenticator),
1305        None,
1306    )
1307}
1308
1309pub fn write_archive_with_dictionary_and_root_auth<F>(
1310    files: &[RegularFile<'_>],
1311    master_key: &MasterKey,
1312    options: WriterOptions,
1313    dictionary: &[u8],
1314    root_auth: RootAuthWriterConfig<'_>,
1315    mut authenticator: F,
1316) -> Result<WrittenArchive, FormatError>
1317where
1318    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
1319{
1320    write_archive_inner(
1321        files,
1322        master_key,
1323        options,
1324        Some(dictionary),
1325        &KdfParams::Raw,
1326        Some(root_auth),
1327        Some(&mut authenticator),
1328        None,
1329    )
1330}
1331
1332pub fn write_archive_with_dictionary_kdf_and_root_auth<F>(
1333    files: &[RegularFile<'_>],
1334    master_key: &MasterKey,
1335    options: WriterOptions,
1336    dictionary: &[u8],
1337    kdf_params: &KdfParams,
1338    root_auth: RootAuthWriterConfig<'_>,
1339    mut authenticator: F,
1340) -> Result<WrittenArchive, FormatError>
1341where
1342    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
1343{
1344    write_archive_inner(
1345        files,
1346        master_key,
1347        options,
1348        Some(dictionary),
1349        kdf_params,
1350        Some(root_auth),
1351        Some(&mut authenticator),
1352        None,
1353    )
1354}
1355
1356pub fn write_archive_with_dictionary(
1357    files: &[RegularFile<'_>],
1358    master_key: &MasterKey,
1359    options: WriterOptions,
1360    dictionary: &[u8],
1361) -> Result<WrittenArchive, FormatError> {
1362    if dictionary.is_empty() {
1363        return Err(FormatError::WriterUnsupported(
1364            "dictionary archives require a non-empty dictionary",
1365        ));
1366    }
1367    if files.is_empty() {
1368        return Err(FormatError::WriterUnsupported(
1369            "dictionary archives require at least one file",
1370        ));
1371    }
1372    if dictionary.len() > u32::MAX as usize {
1373        return Err(FormatError::WriterUnsupported(
1374            "dictionary decompressed size exceeds u32",
1375        ));
1376    }
1377    write_archive_inner(
1378        files,
1379        master_key,
1380        options,
1381        Some(dictionary),
1382        &KdfParams::Raw,
1383        None,
1384        None,
1385        None,
1386    )
1387}
1388
1389pub fn write_archive_with_dictionary_and_kdf(
1390    files: &[RegularFile<'_>],
1391    master_key: &MasterKey,
1392    options: WriterOptions,
1393    dictionary: &[u8],
1394    kdf_params: &KdfParams,
1395) -> Result<WrittenArchive, FormatError> {
1396    if dictionary.is_empty() {
1397        return Err(FormatError::WriterUnsupported(
1398            "dictionary archives require a non-empty dictionary",
1399        ));
1400    }
1401    if files.is_empty() {
1402        return Err(FormatError::WriterUnsupported(
1403            "dictionary archives require at least one file",
1404        ));
1405    }
1406    if dictionary.len() > u32::MAX as usize {
1407        return Err(FormatError::WriterUnsupported(
1408            "dictionary decompressed size exceeds u32",
1409        ));
1410    }
1411    write_archive_inner(
1412        files,
1413        master_key,
1414        options,
1415        Some(dictionary),
1416        kdf_params,
1417        None,
1418        None,
1419        None,
1420    )
1421}
1422
1423#[allow(clippy::too_many_arguments)]
1424pub fn write_archive_sources_to_sink<S, O>(
1425    files: &[S],
1426    master_key: &MasterKey,
1427    options: WriterOptions,
1428    dictionary: Option<&[u8]>,
1429    kdf_params: &KdfParams,
1430    root_auth: Option<RootAuthWriterConfig<'_>>,
1431    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1432    sink: &mut O,
1433) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1434where
1435    S: RegularFileSource,
1436    O: ArchiveWriteSink,
1437{
1438    write_archive_stream_inner(
1439        files,
1440        master_key,
1441        options,
1442        dictionary,
1443        kdf_params,
1444        root_auth,
1445        authenticator,
1446        None,
1447        sink,
1448        None,
1449    )
1450}
1451
1452#[allow(clippy::too_many_arguments)]
1453pub fn write_archive_sources_to_sink_with_progress<S, O>(
1454    files: &[S],
1455    master_key: &MasterKey,
1456    options: WriterOptions,
1457    dictionary: Option<&[u8]>,
1458    kdf_params: &KdfParams,
1459    root_auth: Option<RootAuthWriterConfig<'_>>,
1460    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1461    sink: &mut O,
1462    progress: &mut dyn ArchiveWriteProgressSink,
1463) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1464where
1465    S: RegularFileSource,
1466    O: ArchiveWriteSink,
1467{
1468    let (progress_files, progress_state) = progress_sources(files, progress);
1469    write_archive_stream_inner(
1470        &progress_files,
1471        master_key,
1472        options,
1473        dictionary,
1474        kdf_params,
1475        root_auth,
1476        authenticator,
1477        None,
1478        sink,
1479        Some(&progress_state),
1480    )
1481}
1482
1483#[allow(clippy::too_many_arguments)]
1484pub fn write_archive_sources_to_sink_single_pass<S, O>(
1485    files: &[S],
1486    master_key: &MasterKey,
1487    options: WriterOptions,
1488    kdf_params: &KdfParams,
1489    root_auth: Option<RootAuthWriterConfig<'_>>,
1490    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1491    sink: &mut O,
1492) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1493where
1494    S: RegularFileSource,
1495    O: ArchiveWriteSink,
1496{
1497    write_single_pass_archive_to_sink(
1498        master_key,
1499        options,
1500        kdf_params,
1501        root_auth,
1502        authenticator,
1503        None,
1504        sink,
1505        None,
1506        |writer| {
1507            for file in files {
1508                writer.write_regular_member_from_source(file)?;
1509            }
1510            Ok(())
1511        },
1512    )
1513}
1514
1515#[allow(clippy::too_many_arguments)]
1516pub fn write_archive_sources_to_sink_single_pass_with_recipient_wrap_records<S, O>(
1517    files: &[S],
1518    master_key: &MasterKey,
1519    options: WriterOptions,
1520    records: Vec<RecipientRecordV1>,
1521    root_auth: Option<RootAuthWriterConfig<'_>>,
1522    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1523    sink: &mut O,
1524) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1525where
1526    S: RegularFileSource,
1527    O: ArchiveWriteSink,
1528{
1529    let kdf_params = recipient_wrap_kdf_params_for_record_count(records.len())?;
1530    let key_wrap_records = KeyWrapRecordSource::fixed(records);
1531    write_single_pass_archive_to_sink(
1532        master_key,
1533        options,
1534        &kdf_params,
1535        root_auth,
1536        authenticator,
1537        Some(&key_wrap_records),
1538        sink,
1539        None,
1540        |writer| {
1541            for file in files {
1542                writer.write_regular_member_from_source(file)?;
1543            }
1544            Ok(())
1545        },
1546    )
1547}
1548
1549#[allow(clippy::too_many_arguments)]
1550pub fn write_archive_sources_to_sink_single_pass_with_progress<S, O>(
1551    files: &[S],
1552    master_key: &MasterKey,
1553    options: WriterOptions,
1554    kdf_params: &KdfParams,
1555    root_auth: Option<RootAuthWriterConfig<'_>>,
1556    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1557    sink: &mut O,
1558    progress: &mut dyn ArchiveWriteProgressSink,
1559) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1560where
1561    S: RegularFileSource,
1562    O: ArchiveWriteSink,
1563{
1564    let (progress_files, progress_state) = progress_sources(files, progress);
1565    write_single_pass_archive_to_sink(
1566        master_key,
1567        options,
1568        kdf_params,
1569        root_auth,
1570        authenticator,
1571        None,
1572        sink,
1573        Some(&progress_state),
1574        |writer| {
1575            for file in &progress_files {
1576                writer.write_regular_member_from_source(file)?;
1577            }
1578            Ok(())
1579        },
1580    )
1581}
1582
1583#[allow(clippy::too_many_arguments)]
1584pub fn write_archive_sources_to_sink_ordered_parallel<S, O>(
1585    files: &[S],
1586    master_key: &MasterKey,
1587    options: WriterOptions,
1588    kdf_params: &KdfParams,
1589    root_auth: Option<RootAuthWriterConfig<'_>>,
1590    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1591    sink: &mut O,
1592) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1593where
1594    S: RegularFileSource,
1595    O: ArchiveWriteSink,
1596{
1597    write_ordered_parallel_archive_to_sink(
1598        files,
1599        master_key,
1600        options,
1601        kdf_params,
1602        root_auth,
1603        authenticator,
1604        None,
1605        sink,
1606        None,
1607    )
1608}
1609
1610#[allow(clippy::too_many_arguments)]
1611pub fn write_archive_sources_to_sink_ordered_parallel_with_recipient_wrap_records<S, O>(
1612    files: &[S],
1613    master_key: &MasterKey,
1614    options: WriterOptions,
1615    records: Vec<RecipientRecordV1>,
1616    root_auth: Option<RootAuthWriterConfig<'_>>,
1617    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1618    sink: &mut O,
1619) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1620where
1621    S: RegularFileSource,
1622    O: ArchiveWriteSink,
1623{
1624    let kdf_params = recipient_wrap_kdf_params_for_record_count(records.len())?;
1625    let key_wrap_records = KeyWrapRecordSource::fixed(records);
1626    write_ordered_parallel_archive_to_sink(
1627        files,
1628        master_key,
1629        options,
1630        &kdf_params,
1631        root_auth,
1632        authenticator,
1633        Some(&key_wrap_records),
1634        sink,
1635        None,
1636    )
1637}
1638
1639#[allow(clippy::too_many_arguments)]
1640pub fn write_archive_sources_to_sink_ordered_parallel_with_recipient_wrap_records_and_progress<
1641    S,
1642    O,
1643>(
1644    files: &[S],
1645    master_key: &MasterKey,
1646    options: WriterOptions,
1647    records: Vec<RecipientRecordV1>,
1648    root_auth: Option<RootAuthWriterConfig<'_>>,
1649    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1650    sink: &mut O,
1651    progress: &mut dyn ArchiveWriteProgressSink,
1652) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1653where
1654    S: RegularFileSource,
1655    O: ArchiveWriteSink,
1656{
1657    let kdf_params = recipient_wrap_kdf_params_for_record_count(records.len())?;
1658    let key_wrap_records = KeyWrapRecordSource::fixed(records);
1659    let (progress_files, progress_state) = progress_sources(files, progress);
1660    write_ordered_parallel_archive_to_sink(
1661        &progress_files,
1662        master_key,
1663        options,
1664        &kdf_params,
1665        root_auth,
1666        authenticator,
1667        Some(&key_wrap_records),
1668        sink,
1669        Some(&progress_state),
1670    )
1671}
1672
1673#[allow(clippy::too_many_arguments)]
1674pub fn write_archive_sources_to_sink_ordered_parallel_with_progress<S, O>(
1675    files: &[S],
1676    master_key: &MasterKey,
1677    options: WriterOptions,
1678    kdf_params: &KdfParams,
1679    root_auth: Option<RootAuthWriterConfig<'_>>,
1680    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1681    sink: &mut O,
1682    progress: &mut dyn ArchiveWriteProgressSink,
1683) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1684where
1685    S: RegularFileSource,
1686    O: ArchiveWriteSink,
1687{
1688    let (progress_files, progress_state) = progress_sources(files, progress);
1689    write_ordered_parallel_archive_to_sink(
1690        &progress_files,
1691        master_key,
1692        options,
1693        kdf_params,
1694        root_auth,
1695        authenticator,
1696        None,
1697        sink,
1698        Some(&progress_state),
1699    )
1700}
1701
1702#[allow(clippy::too_many_arguments)]
1703fn write_archive_inner(
1704    files: &[RegularFile<'_>],
1705    master_key: &MasterKey,
1706    options: WriterOptions,
1707    dictionary: Option<&[u8]>,
1708    kdf_params: &KdfParams,
1709    root_auth: Option<RootAuthWriterConfig<'_>>,
1710    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1711    key_wrap_records: Option<&KeyWrapRecordSource>,
1712) -> Result<WrittenArchive, FormatError> {
1713    let mut sink = MemoryArchiveSink::default();
1714    let summary = write_archive_stream_inner(
1715        files,
1716        master_key,
1717        options,
1718        dictionary,
1719        kdf_params,
1720        root_auth,
1721        authenticator,
1722        key_wrap_records,
1723        &mut sink,
1724        None,
1725    )
1726    .map_err(format_error_from_archive_write_error)?;
1727    Ok(WrittenArchive {
1728        bytes: sink
1729            .volumes
1730            .first()
1731            .cloned()
1732            .ok_or(FormatError::WriterInvariant("no volumes emitted"))?,
1733        volumes: sink.volumes,
1734        bootstrap_sidecar: sink.bootstrap_sidecar,
1735        archive_uuid: summary.archive_uuid,
1736        session_id: summary.session_id,
1737        timings: summary.timings,
1738    })
1739}
1740
1741fn format_error_from_archive_write_error(error: ArchiveWriteError) -> FormatError {
1742    match error {
1743        ArchiveWriteError::Format(error) => error,
1744        ArchiveWriteError::Io(_) => {
1745            FormatError::WriterInvariant("in-memory archive writer returned I/O")
1746        }
1747    }
1748}
1749
1750fn writer_subkeys(
1751    master_key: &MasterKey,
1752    aead_algo: AeadAlgo,
1753    archive_uuid: &[u8; 16],
1754    session_id: &[u8; 16],
1755) -> Result<Subkeys, FormatError> {
1756    if aead_algo.is_encrypted() {
1757        Subkeys::derive(master_key, archive_uuid, session_id)
1758    } else {
1759        Ok(Subkeys::unencrypted_placeholder())
1760    }
1761}
1762
1763#[allow(clippy::too_many_arguments)]
1764fn write_archive_stream_inner<S, O>(
1765    files: &[S],
1766    master_key: &MasterKey,
1767    options: WriterOptions,
1768    dictionary: Option<&[u8]>,
1769    kdf_params: &KdfParams,
1770    root_auth: Option<RootAuthWriterConfig<'_>>,
1771    mut authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1772    key_wrap_records: Option<&KeyWrapRecordSource>,
1773    sink: &mut O,
1774    progress: Option<&SourceProgressHandle<'_>>,
1775) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1776where
1777    S: RegularFileSource,
1778    O: ArchiveWriteSink,
1779{
1780    let total_started = Instant::now();
1781    validate_dictionary_inputs(files.is_empty(), dictionary)?;
1782    if let Some(root_auth) = root_auth {
1783        validate_root_auth_writer_config(root_auth)?;
1784    }
1785    let mut requested_options = options;
1786    if requested_options.target_volume_size.is_some() {
1787        requested_options.stripe_width = requested_options
1788            .stripe_width
1789            .max(requested_options.volume_loss_tolerance as u32 + 1);
1790    }
1791    let archive_uuid = requested_options
1792        .archive_uuid
1793        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1794    let session_id = requested_options
1795        .session_id
1796        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1797    let mut accumulated_timings = WriterTimings::default();
1798
1799    loop {
1800        let planned_options = plan_writer_options(requested_options)?;
1801        start_write_phase(progress, ArchiveWritePhase::PlanningPayload);
1802        let timed_plan = build_writer_plan(
1803            files,
1804            master_key,
1805            planned_options,
1806            dictionary,
1807            kdf_params,
1808            key_wrap_records,
1809            archive_uuid,
1810            session_id,
1811            root_auth,
1812            progress,
1813        )?;
1814        accumulated_timings.add_assign(timed_plan.timings);
1815        let plan = timed_plan.plan;
1816        if let Some(target_volume_size) = planned_options.target_volume_size {
1817            let required = required_stripe_width_for_plan(&plan, master_key, target_volume_size)?;
1818            if required > planned_options.stripe_width {
1819                requested_options.stripe_width = required;
1820                continue;
1821            }
1822        }
1823        start_write_phase(progress, ArchiveWritePhase::EmittingPayload);
1824        let mut summary = emit_writer_plan(
1825            files,
1826            master_key,
1827            dictionary,
1828            root_auth,
1829            authenticator.take(),
1830            plan,
1831            sink,
1832            progress,
1833        )?;
1834        summary.timings.add_assign(accumulated_timings);
1835        summary.timings.total = total_started.elapsed();
1836        return Ok(summary);
1837    }
1838}
1839
1840fn validate_dictionary_inputs(
1841    files_are_empty: bool,
1842    dictionary: Option<&[u8]>,
1843) -> Result<(), FormatError> {
1844    if let Some(dictionary) = dictionary {
1845        if dictionary.is_empty() {
1846            return Err(FormatError::WriterUnsupported(
1847                "dictionary archives require a non-empty dictionary",
1848            ));
1849        }
1850        if files_are_empty {
1851            return Err(FormatError::WriterUnsupported(
1852                "dictionary archives require at least one file",
1853            ));
1854        }
1855        if dictionary.len() > u32::MAX as usize {
1856            return Err(FormatError::WriterUnsupported(
1857                "dictionary decompressed size exceeds u32",
1858            ));
1859        }
1860    }
1861    Ok(())
1862}
1863
1864#[allow(clippy::too_many_arguments)]
1865struct TimedWriterPlan {
1866    plan: WriterPlan,
1867    timings: WriterTimings,
1868}
1869
1870#[allow(clippy::too_many_arguments)]
1871fn build_writer_plan<S: RegularFileSource>(
1872    files: &[S],
1873    master_key: &MasterKey,
1874    options: WriterOptions,
1875    dictionary: Option<&[u8]>,
1876    kdf_params: &KdfParams,
1877    key_wrap_records: Option<&KeyWrapRecordSource>,
1878    archive_uuid: [u8; 16],
1879    session_id: [u8; 16],
1880    root_auth: Option<RootAuthWriterConfig<'_>>,
1881    progress: Option<&SourceProgressHandle<'_>>,
1882) -> Result<TimedWriterPlan, ArchiveWriteError> {
1883    let mut next_block_index = 0u64;
1884    let payload_started = Instant::now();
1885    let payload = plan_payload_stream(files, options, dictionary, &mut next_block_index)?;
1886    let plan_payload = payload_started.elapsed();
1887    start_write_phase(progress, ArchiveWritePhase::PlanningMetadata);
1888    let metadata_started = Instant::now();
1889    let plan = build_writer_plan_from_payload(
1890        payload,
1891        next_block_index,
1892        master_key,
1893        options,
1894        dictionary,
1895        kdf_params,
1896        key_wrap_records,
1897        archive_uuid,
1898        session_id,
1899        root_auth,
1900    )?;
1901    Ok(TimedWriterPlan {
1902        plan,
1903        timings: WriterTimings {
1904            plan_payload,
1905            plan_metadata: metadata_started.elapsed(),
1906            ..WriterTimings::default()
1907        },
1908    })
1909}
1910
1911#[allow(clippy::too_many_arguments)]
1912fn build_writer_plan_from_payload(
1913    payload: PayloadPlanning,
1914    mut next_block_index: u64,
1915    master_key: &MasterKey,
1916    mut options: WriterOptions,
1917    dictionary: Option<&[u8]>,
1918    kdf_params: &KdfParams,
1919    key_wrap_records: Option<&KeyWrapRecordSource>,
1920    archive_uuid: [u8; 16],
1921    session_id: [u8; 16],
1922    root_auth: Option<RootAuthWriterConfig<'_>>,
1923) -> Result<WriterPlan, ArchiveWriteError> {
1924    let subkeys = writer_subkeys(master_key, options.aead_algo, &archive_uuid, &session_id)?;
1925    let (resolved_kdf_params, key_wrap_table) =
1926        resolve_key_wrap_artifacts(kdf_params, &archive_uuid, &session_id, key_wrap_records)?;
1927    let volume_format_rev = volume_format_revision_for_options(&options, &resolved_kdf_params);
1928    let (shard_file_rows, planned_index_shards) = if payload.tar_members.is_empty() {
1929        (Vec::new(), Vec::new())
1930    } else {
1931        let rows = sorted_file_rows(&payload.tar_members);
1932        let shard_file_rows = partition_file_rows(rows)?;
1933        let planned_index_shards = build_index_shard_plaintexts(
1934            &shard_file_rows,
1935            &payload.frames,
1936            &payload.payload_objects,
1937            options,
1938        )?;
1939        (shard_file_rows, planned_index_shards)
1940    };
1941
1942    let mut shard_entries = Vec::with_capacity(planned_index_shards.len());
1943    let mut index_shard_objects = Vec::with_capacity(planned_index_shards.len());
1944    for planned in planned_index_shards {
1945        let compressed =
1946            compress_zstd_frame_with_jobs(&planned.plaintext, options.zstd_level, options.jobs)?;
1947        let object_plan = plan_encrypted_object(
1948            compressed.len(),
1949            options.index_fec_data_shards,
1950            options.index_fec_parity_shards,
1951            options,
1952        )?;
1953        let extent = ObjectExtent::new(next_block_index, object_plan)?;
1954        next_block_index = extent.next_block_index()?;
1955        shard_entries.push(ShardEntry {
1956            shard_index: planned.shard_index,
1957            first_block_index: extent.first_block_index,
1958            data_block_count: extent.data_block_count,
1959            parity_block_count: extent.parity_block_count,
1960            encrypted_size: extent.encrypted_size,
1961            decompressed_size: u32_len(planned.plaintext.len(), "IndexShard")?,
1962            file_count: planned.file_count,
1963            first_path_hash: planned.first_path_hash,
1964            last_path_hash: planned.last_path_hash,
1965        });
1966        index_shard_objects.push(PlannedIndexShardObject {
1967            shard_index: planned.shard_index,
1968            compressed,
1969            extent,
1970        });
1971    }
1972
1973    let compressed_dictionary = dictionary
1974        .map(|dictionary| {
1975            compress_zstd_frame_with_jobs(dictionary, options.zstd_level, options.jobs)
1976        })
1977        .transpose()?;
1978    let dictionary_decompressed_size = dictionary
1979        .map(|dictionary| u32_len(dictionary.len(), "dictionary"))
1980        .transpose()?;
1981    let dictionary_plan = compressed_dictionary
1982        .as_ref()
1983        .map(|compressed| {
1984            plan_metadata_object_without_class(
1985                compressed.len(),
1986                options,
1987                MetadataObjectKind::Dictionary,
1988            )
1989        })
1990        .transpose()?;
1991    let dictionary_extent = dictionary_plan
1992        .map(|plan| ObjectExtent::new(next_block_index, plan))
1993        .transpose()?;
1994    let next_after_dictionary = if let Some(extent) = dictionary_extent {
1995        extent.next_block_index()?
1996    } else {
1997        next_block_index
1998    };
1999
2000    let planned_directory_hint_shards = if should_emit_directory_hints(payload.tar_members.len()) {
2001        build_directory_hint_plaintexts(&shard_file_rows, options)?
2002    } else {
2003        Vec::new()
2004    };
2005    let mut directory_hint_entries = Vec::with_capacity(planned_directory_hint_shards.len());
2006    let mut directory_hint_objects = Vec::with_capacity(planned_directory_hint_shards.len());
2007    let mut planned_next_block_index = next_after_dictionary;
2008    for planned in planned_directory_hint_shards {
2009        let compressed =
2010            compress_zstd_frame_with_jobs(&planned.plaintext, options.zstd_level, options.jobs)?;
2011        let object_plan = plan_encrypted_object(
2012            compressed.len(),
2013            options.index_fec_data_shards,
2014            options.index_fec_parity_shards,
2015            options,
2016        )?;
2017        let extent = ObjectExtent::new(planned_next_block_index, object_plan)?;
2018        planned_next_block_index = extent.next_block_index()?;
2019        directory_hint_entries.push(DirectoryHintShardEntry {
2020            hint_shard_index: planned.hint_shard_index,
2021            first_dir_hash: planned.first_dir_hash,
2022            last_dir_hash: planned.last_dir_hash,
2023            first_block_index: extent.first_block_index,
2024            data_block_count: extent.data_block_count,
2025            parity_block_count: extent.parity_block_count,
2026            encrypted_size: extent.encrypted_size,
2027            decompressed_size: u32_len(planned.plaintext.len(), "DirectoryHintTable")?,
2028            entry_count: planned.entry_count,
2029        });
2030        directory_hint_objects.push(PlannedDirectoryHintObject {
2031            hint_shard_index: planned.hint_shard_index,
2032            compressed,
2033            extent,
2034        });
2035    }
2036
2037    let dictionary_extent = dictionary_extent.zip(dictionary_decompressed_size);
2038    let index_root_plaintext = build_index_root_plaintext(IndexRootPlaintextInput {
2039        shard_entries: &shard_entries,
2040        frame_count: payload.frames.len() as u64,
2041        envelope_count: payload.payload_objects.len() as u64,
2042        file_count: payload.tar_members.len() as u64,
2043        payload_block_count: payload.payload_block_count,
2044        tar_total_size: payload.tar_total_size,
2045        content_sha256: payload.content_sha256,
2046        directory_hint_entries: &directory_hint_entries,
2047        dictionary_extent,
2048    });
2049    let compressed_index_root =
2050        compress_zstd_frame_with_jobs(&index_root_plaintext, options.zstd_level, options.jobs)?;
2051    let metadata_class = plan_index_root_metadata_class(
2052        options,
2053        compressed_index_root.len(),
2054        compressed_dictionary.as_ref().map(Vec::len),
2055    )?;
2056    options = metadata_class.options;
2057    let crypto_header = build_crypto_header(
2058        options,
2059        volume_format_rev,
2060        dictionary.is_some(),
2061        &subkeys,
2062        &archive_uuid,
2063        &session_id,
2064        &resolved_kdf_params,
2065    )?;
2066    let index_root_extent = ObjectExtent::new(planned_next_block_index, metadata_class.index_root)?;
2067    let total_block_count = index_root_extent.next_block_index()?;
2068    let root_auth_footer_length = root_auth
2069        .map(|config| {
2070            root_auth_footer_wire_length(
2071                config.signer_identity.len(),
2072                config.authenticator_value_length as usize,
2073            )
2074        })
2075        .transpose()?;
2076    let block_records_offset = checked_u64_add(
2077        checked_u64_add(
2078            VOLUME_HEADER_LEN as u64,
2079            crypto_header.len() as u64,
2080            "CryptoHeader",
2081        )?,
2082        key_wrap_table.as_ref().map(Vec::len).unwrap_or(0) as u64,
2083        "KeyWrapTableV1",
2084    )?;
2085
2086    Ok(WriterPlan {
2087        options,
2088        archive_uuid,
2089        session_id,
2090        crypto_header,
2091        tar_members: payload.tar_members,
2092        frames: payload.frames,
2093        payload_objects: payload.payload_objects,
2094        index_root_plaintext,
2095        compressed_index_root,
2096        index_root_extent,
2097        index_shard_objects,
2098        shard_entries,
2099        compressed_dictionary,
2100        dictionary_extent,
2101        volume_format_rev,
2102        directory_hint_objects,
2103        directory_hint_entries,
2104        root_auth_footer_length,
2105        key_wrap_table,
2106        block_records_offset,
2107        total_block_count,
2108    })
2109}
2110
2111fn plan_payload_stream<S: RegularFileSource>(
2112    files: &[S],
2113    options: WriterOptions,
2114    dictionary: Option<&[u8]>,
2115    next_block_index: &mut u64,
2116) -> Result<PayloadPlanning, ArchiveWriteError> {
2117    let mut tar_members = Vec::with_capacity(files.len());
2118    let mut frames = Vec::new();
2119    let mut payload_objects = Vec::new();
2120    let mut tar_total_size = 0u64;
2121    let mut hasher = Sha256::new();
2122    let mut payload_block_count = 0u64;
2123    let mut next_frame_index = 0u64;
2124    let mut envelope = PayloadEnvelopeBuilder {
2125        envelope_index: 0,
2126        plaintext: Vec::new(),
2127    };
2128
2129    for (member_index, file) in files.iter().enumerate() {
2130        let path = normalize_lookup_file_path(file.archive_path(), options.max_path_length)?;
2131        let entry_kind = file.entry_kind();
2132        if entry_kind != SourceEntryKind::Regular && file.file_data_size() != 0 {
2133            return Err(FormatError::WriterInvariant(
2134                "non-regular source has non-zero file data size",
2135            )
2136            .into());
2137        }
2138        let link_target = file.link_target().map(<[u8]>::to_vec);
2139        let sparse_extents = file.sparse_extents().map(<[SparseExtent]>::to_vec);
2140        let source_payload_size = sparse_extents
2141            .as_deref()
2142            .map(|extents| sparse_extent_bytes(extents, file.file_data_size()))
2143            .transpose()?
2144            .unwrap_or(file.file_data_size());
2145        let portable_metadata = file.portable_metadata();
2146        let layout = build_primary_member_layout(
2147            &path,
2148            entry_kind,
2149            link_target.as_deref(),
2150            file.file_data_size(),
2151            sparse_extents.as_deref(),
2152            file.mode(),
2153            file.mtime(),
2154            &portable_metadata,
2155        )?;
2156        let member_start = tar_total_size;
2157        let member_group_size = primary_member_layout_size(&layout, source_payload_size)?;
2158        let mut reader = StreamingMemberReader::from_source(
2159            file,
2160            &portable_metadata,
2161            layout,
2162            source_payload_size,
2163        )?;
2164        tar_members.push(TarMember {
2165            path,
2166            entry_kind,
2167            link_target,
2168            tar_member_group_start: member_start,
2169            tar_member_group_size: member_group_size,
2170            file_data_size: file.file_data_size(),
2171            sparse_extents,
2172            mode: file.mode(),
2173            mtime: file.mtime(),
2174            portable_metadata,
2175        });
2176        let mut member_offset = 0u64;
2177        while member_offset < member_group_size {
2178            let remaining = member_group_size - member_offset;
2179            let max_chunk = remaining.min(options.chunk_size as u64);
2180            let mut chunk = vec![0u8; to_usize_writer(max_chunk, "payload chunk")?];
2181            reader
2182                .read_exact(&mut chunk)
2183                .map_err(ArchiveWriteError::Io)?;
2184            let mut chunk_len = chunk.len();
2185            let frame = loop {
2186                let candidate = &chunk[..chunk_len];
2187                let frame = if let Some(dictionary) = dictionary {
2188                    compress_zstd_frame_with_dictionary_and_jobs(
2189                        candidate,
2190                        options.zstd_level,
2191                        dictionary,
2192                        options.jobs,
2193                    )?
2194                } else {
2195                    compress_zstd_frame_with_jobs(candidate, options.zstd_level, options.jobs)?
2196                };
2197                if payload_object_can_fit(frame.len(), options)? {
2198                    break frame;
2199                }
2200                if chunk_len == 1 {
2201                    return Err(FormatError::WriterUnsupported(
2202                        "single-byte payload frame exceeds envelope object limits",
2203                    )
2204                    .into());
2205                }
2206                chunk_len = (chunk_len / 2).max(1);
2207            };
2208            if chunk_len < chunk.len() {
2209                reader.push_back(chunk[chunk_len..].to_vec());
2210            }
2211            let chunk = &chunk[..chunk_len];
2212            hasher.update(chunk);
2213            append_payload_frame_to_plan(
2214                PayloadFramePlanState {
2215                    envelope: &mut envelope,
2216                    payload_objects: &mut payload_objects,
2217                    payload_block_count: &mut payload_block_count,
2218                    next_block_index,
2219                    frames: &mut frames,
2220                    next_frame_index: &mut next_frame_index,
2221                    options,
2222                },
2223                PayloadFramePlanInput {
2224                    frame: &frame,
2225                    decompressed_size: chunk_len,
2226                    member_index,
2227                    member_start,
2228                    member_offset,
2229                    member_group_size,
2230                },
2231            )?;
2232            member_offset = checked_u64_add(member_offset, chunk_len as u64, "payload chunk")?;
2233            tar_total_size = checked_u64_add(tar_total_size, chunk_len as u64, "tar stream")?;
2234        }
2235    }
2236
2237    if !envelope.plaintext.is_empty() {
2238        flush_payload_envelope_plan(
2239            &mut envelope,
2240            &mut payload_objects,
2241            &mut payload_block_count,
2242            next_block_index,
2243            options,
2244        )?;
2245    }
2246    let digest = hasher.finalize();
2247    let mut content_sha256 = [0u8; 32];
2248    content_sha256.copy_from_slice(&digest);
2249    Ok(PayloadPlanning {
2250        tar_members,
2251        frames,
2252        payload_objects,
2253        payload_block_count,
2254        tar_total_size,
2255        content_sha256,
2256    })
2257}
2258
2259#[allow(clippy::too_many_arguments)]
2260pub(crate) fn write_single_pass_archive_to_sink<O, F>(
2261    master_key: &MasterKey,
2262    options: WriterOptions,
2263    kdf_params: &KdfParams,
2264    root_auth: Option<RootAuthWriterConfig<'_>>,
2265    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
2266    key_wrap_records: Option<&KeyWrapRecordSource>,
2267    sink: &mut O,
2268    progress: Option<&SourceProgressHandle<'_>>,
2269    drive_members: F,
2270) -> Result<WrittenArchiveSummary, ArchiveWriteError>
2271where
2272    O: ArchiveWriteSink,
2273    F: FnOnce(&mut StreamingArchiveWriter<'_, O>) -> Result<(), ArchiveWriteError>,
2274{
2275    let total_started = Instant::now();
2276    validate_single_pass_writer_options(options)?;
2277    if let Some(root_auth) = root_auth {
2278        validate_root_auth_writer_config(root_auth)?;
2279    }
2280    let options = plan_single_pass_writer_options(options)?;
2281    let archive_uuid = options
2282        .archive_uuid
2283        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
2284    let session_id = options
2285        .session_id
2286        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
2287    let stabilized_key_wrap_records =
2288        stabilized_key_wrap_record_source(kdf_params, key_wrap_records)?;
2289    let key_wrap_records = stabilized_key_wrap_records.as_ref().or(key_wrap_records);
2290    let (resolved_kdf_params, key_wrap_table) =
2291        resolve_key_wrap_artifacts(kdf_params, &archive_uuid, &session_id, key_wrap_records)?;
2292    let volume_format_rev = volume_format_revision_for_options(&options, &resolved_kdf_params);
2293    let subkeys = writer_subkeys(master_key, options.aead_algo, &archive_uuid, &session_id)?;
2294    let crypto_header = build_crypto_header(
2295        options,
2296        volume_format_rev,
2297        false,
2298        &subkeys,
2299        &archive_uuid,
2300        &session_id,
2301        &resolved_kdf_params,
2302    )?;
2303    let emission_state = begin_writer_emission_state(
2304        sink,
2305        options,
2306        &crypto_header,
2307        key_wrap_table.as_deref(),
2308        archive_uuid,
2309        session_id,
2310        volume_format_rev,
2311        root_auth.is_some(),
2312    )?;
2313
2314    let mut writer = StreamingArchiveWriter {
2315        sink,
2316        options,
2317        archive_uuid,
2318        session_id,
2319        crypto_header,
2320        subkeys,
2321        tar_members: Vec::new(),
2322        frames: Vec::new(),
2323        payload_objects: Vec::new(),
2324        payload_block_count: 0,
2325        tar_total_size: 0,
2326        hasher: Sha256::new(),
2327        next_frame_index: 0,
2328        envelope: PayloadEnvelopeBuilder {
2329            envelope_index: 0,
2330            plaintext: Vec::new(),
2331        },
2332        emission_state,
2333    };
2334    start_write_phase(progress, ArchiveWritePhase::EmittingPayload);
2335    let emit_payload_started = Instant::now();
2336    drive_members(&mut writer)?;
2337    let emit_payload = emit_payload_started.elapsed();
2338    let mut summary = writer.finish(
2339        master_key,
2340        &resolved_kdf_params,
2341        key_wrap_records,
2342        root_auth,
2343        authenticator,
2344        progress,
2345    )?;
2346    summary.timings.emit_payload += emit_payload;
2347    summary.timings.total = total_started.elapsed();
2348    Ok(summary)
2349}
2350
2351struct OrderedFrameJob {
2352    frame_index: u64,
2353    member_index: usize,
2354    member_start: u64,
2355    member_offset: u64,
2356    member_group_size: u64,
2357    plaintext: Vec<u8>,
2358}
2359
2360struct OrderedFrameResult {
2361    frame_index: u64,
2362    member_index: usize,
2363    member_start: u64,
2364    member_offset: u64,
2365    member_group_size: u64,
2366    decompressed_size: usize,
2367    frame: Vec<u8>,
2368}
2369
2370struct OrderedEnvelopeJob {
2371    envelope_index: u64,
2372    plaintext: Vec<u8>,
2373    extent: ObjectExtent,
2374    collect_data_leaf_hashes: bool,
2375}
2376
2377struct OrderedEnvelopeResult {
2378    envelope_index: u64,
2379    records: OrderedEnvelopeRecords,
2380}
2381
2382enum OrderedEnvelopeRecords {
2383    Materialized(Vec<BlockRecord>),
2384    Serialized(Vec<SerializedBlockRecord>),
2385}
2386
2387struct SerializedBlockRecord {
2388    block_index: u64,
2389    bytes: Vec<u8>,
2390}
2391
2392struct OrderedParallelState {
2393    tar_members: Vec<TarMember>,
2394    frames: Vec<PayloadFrame>,
2395    payload_objects: Vec<PayloadObject>,
2396    payload_block_count: u64,
2397    tar_total_size: u64,
2398    hasher: Sha256,
2399    next_frame_job_index: u64,
2400    next_frame_result_index: u64,
2401    next_frame_metadata_index: u64,
2402    frame_buffer: std::collections::BTreeMap<u64, OrderedFrameResult>,
2403    envelope: PayloadEnvelopeBuilder,
2404    next_payload_block_index: u64,
2405    next_envelope_result_index: u64,
2406    envelope_buffer: std::collections::BTreeMap<u64, OrderedEnvelopeResult>,
2407}
2408
2409impl OrderedParallelState {
2410    fn new(file_count: usize) -> Self {
2411        Self {
2412            tar_members: Vec::with_capacity(file_count),
2413            frames: Vec::new(),
2414            payload_objects: Vec::new(),
2415            payload_block_count: 0,
2416            tar_total_size: 0,
2417            hasher: Sha256::new(),
2418            next_frame_job_index: 0,
2419            next_frame_result_index: 0,
2420            next_frame_metadata_index: 0,
2421            frame_buffer: std::collections::BTreeMap::new(),
2422            envelope: PayloadEnvelopeBuilder {
2423                envelope_index: 0,
2424                plaintext: Vec::new(),
2425            },
2426            next_payload_block_index: 0,
2427            next_envelope_result_index: 0,
2428            envelope_buffer: std::collections::BTreeMap::new(),
2429        }
2430    }
2431}
2432
2433#[allow(clippy::too_many_arguments)]
2434fn write_ordered_parallel_archive_to_sink<S, O>(
2435    files: &[S],
2436    master_key: &MasterKey,
2437    options: WriterOptions,
2438    kdf_params: &KdfParams,
2439    root_auth: Option<RootAuthWriterConfig<'_>>,
2440    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
2441    key_wrap_records: Option<&KeyWrapRecordSource>,
2442    sink: &mut O,
2443    progress: Option<&SourceProgressHandle<'_>>,
2444) -> Result<WrittenArchiveSummary, ArchiveWriteError>
2445where
2446    S: RegularFileSource,
2447    O: ArchiveWriteSink,
2448{
2449    write_ordered_parallel_stream_archive_to_sink(
2450        master_key,
2451        options,
2452        kdf_params,
2453        root_auth,
2454        authenticator,
2455        key_wrap_records,
2456        sink,
2457        progress,
2458        |writer| {
2459            writer.reserve_member_capacity(files.len());
2460            for file in files {
2461                writer.write_regular_member_from_source(file)?;
2462            }
2463            Ok(())
2464        },
2465    )
2466}
2467
2468#[allow(clippy::too_many_arguments)]
2469pub(crate) fn write_ordered_parallel_stream_archive_to_sink<O, F>(
2470    master_key: &MasterKey,
2471    options: WriterOptions,
2472    kdf_params: &KdfParams,
2473    root_auth: Option<RootAuthWriterConfig<'_>>,
2474    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
2475    key_wrap_records: Option<&KeyWrapRecordSource>,
2476    sink: &mut O,
2477    progress: Option<&SourceProgressHandle<'_>>,
2478    drive_members: F,
2479) -> Result<WrittenArchiveSummary, ArchiveWriteError>
2480where
2481    O: ArchiveWriteSink,
2482    F: FnOnce(&mut OrderedParallelArchiveWriter<'_, O>) -> Result<(), ArchiveWriteError>,
2483{
2484    let total_started = Instant::now();
2485    validate_single_pass_writer_options(options)?;
2486    if let Some(root_auth) = root_auth {
2487        validate_root_auth_writer_config(root_auth)?;
2488    }
2489    let options = plan_single_pass_writer_options(options)?;
2490    let archive_uuid = options
2491        .archive_uuid
2492        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
2493    let session_id = options
2494        .session_id
2495        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
2496    let stabilized_key_wrap_records =
2497        stabilized_key_wrap_record_source(kdf_params, key_wrap_records)?;
2498    let key_wrap_records = stabilized_key_wrap_records.as_ref().or(key_wrap_records);
2499    let (resolved_kdf_params, key_wrap_table) =
2500        resolve_key_wrap_artifacts(kdf_params, &archive_uuid, &session_id, key_wrap_records)?;
2501    let volume_format_rev = volume_format_revision_for_options(&options, &resolved_kdf_params);
2502    let subkeys = writer_subkeys(master_key, options.aead_algo, &archive_uuid, &session_id)?;
2503    let crypto_header = build_crypto_header(
2504        options,
2505        volume_format_rev,
2506        false,
2507        &subkeys,
2508        &archive_uuid,
2509        &session_id,
2510        &resolved_kdf_params,
2511    )?;
2512    let mut emission_state = begin_writer_emission_state(
2513        sink,
2514        options,
2515        &crypto_header,
2516        key_wrap_table.as_deref(),
2517        archive_uuid,
2518        session_id,
2519        volume_format_rev,
2520        root_auth.is_some(),
2521    )?;
2522
2523    start_write_phase(progress, ArchiveWritePhase::EmittingPayload);
2524    let emit_payload_started = Instant::now();
2525    let mut ordered = OrderedParallelState::new(0);
2526    let worker_count = options.jobs.max(1);
2527    let frame_job_buffer = worker_count.saturating_mul(4).max(1);
2528    let envelope_job_buffer = worker_count.saturating_mul(2).max(1);
2529    let subkeys_for_workers = std::sync::Arc::new(subkeys.clone());
2530
2531    std::thread::scope(|scope| -> Result<(), ArchiveWriteError> {
2532        let (frame_job_tx, frame_job_rx) =
2533            std::sync::mpsc::sync_channel::<OrderedFrameJob>(frame_job_buffer);
2534        let (frame_result_tx, frame_result_rx) =
2535            std::sync::mpsc::channel::<Result<OrderedFrameResult, ArchiveWriteError>>();
2536        let frame_job_rx = std::sync::Arc::new(std::sync::Mutex::new(frame_job_rx));
2537
2538        let (envelope_job_tx, envelope_job_rx) =
2539            std::sync::mpsc::sync_channel::<OrderedEnvelopeJob>(envelope_job_buffer);
2540        let (envelope_result_tx, envelope_result_rx) =
2541            std::sync::mpsc::channel::<Result<OrderedEnvelopeResult, ArchiveWriteError>>();
2542        let envelope_job_rx = std::sync::Arc::new(std::sync::Mutex::new(envelope_job_rx));
2543
2544        let frame_handles = (0..worker_count)
2545            .map(|_| {
2546                let frame_job_rx = std::sync::Arc::clone(&frame_job_rx);
2547                let frame_result_tx = frame_result_tx.clone();
2548                scope.spawn(move || loop {
2549                    let job = {
2550                        let receiver = frame_job_rx
2551                            .lock()
2552                            .expect("ordered frame receiver poisoned");
2553                        receiver.recv()
2554                    };
2555                    let Ok(job) = job else {
2556                        break;
2557                    };
2558                    let is_error = match build_ordered_frame_result(job, options) {
2559                        Ok(result) => frame_result_tx.send(Ok(result)).is_err(),
2560                        Err(error) => {
2561                            let _ = frame_result_tx.send(Err(error));
2562                            true
2563                        }
2564                    };
2565                    if is_error {
2566                        break;
2567                    }
2568                })
2569            })
2570            .collect::<Vec<_>>();
2571        drop(frame_result_tx);
2572
2573        let envelope_handles = (0..worker_count)
2574            .map(|_| {
2575                let envelope_job_rx = std::sync::Arc::clone(&envelope_job_rx);
2576                let envelope_result_tx = envelope_result_tx.clone();
2577                let subkeys = std::sync::Arc::clone(&subkeys_for_workers);
2578                scope.spawn(move || loop {
2579                    let job = {
2580                        let receiver = envelope_job_rx
2581                            .lock()
2582                            .expect("ordered envelope receiver poisoned");
2583                        receiver.recv()
2584                    };
2585                    let Ok(job) = job else {
2586                        break;
2587                    };
2588                    let is_error = match build_ordered_envelope_result(
2589                        job,
2590                        &subkeys,
2591                        options,
2592                        archive_uuid,
2593                        session_id,
2594                    ) {
2595                        Ok(result) => envelope_result_tx.send(Ok(result)).is_err(),
2596                        Err(error) => {
2597                            let _ = envelope_result_tx.send(Err(error));
2598                            true
2599                        }
2600                    };
2601                    if is_error {
2602                        break;
2603                    }
2604                })
2605            })
2606            .collect::<Vec<_>>();
2607        drop(envelope_result_tx);
2608
2609        {
2610            let mut writer = OrderedParallelArchiveWriter {
2611                frame_job_tx: &frame_job_tx,
2612                frame_result_rx: &frame_result_rx,
2613                envelope_job_tx: &envelope_job_tx,
2614                envelope_result_rx: &envelope_result_rx,
2615                ordered: &mut ordered,
2616                sink,
2617                options,
2618                emission_state: &mut emission_state,
2619            };
2620            drive_members(&mut writer)?;
2621        }
2622
2623        drop(frame_job_tx);
2624        while ordered.next_frame_result_index < ordered.next_frame_job_index {
2625            receive_ordered_frame_result(
2626                &frame_result_rx,
2627                &envelope_job_tx,
2628                &envelope_result_rx,
2629                &mut ordered,
2630                sink,
2631                options,
2632                &mut emission_state,
2633                true,
2634            )?;
2635        }
2636        flush_ordered_parallel_envelope(
2637            &envelope_job_tx,
2638            &envelope_result_rx,
2639            &mut ordered,
2640            sink,
2641            options,
2642            &mut emission_state,
2643        )?;
2644        drop(envelope_job_tx);
2645        while ordered.next_envelope_result_index < ordered.envelope.envelope_index {
2646            receive_ordered_envelope_result(
2647                &envelope_result_rx,
2648                &mut ordered,
2649                sink,
2650                options,
2651                &mut emission_state,
2652                true,
2653            )?;
2654        }
2655
2656        for handle in frame_handles {
2657            handle
2658                .join()
2659                .map_err(|_| FormatError::WriterInvariant("ordered frame worker panicked"))?;
2660        }
2661        for handle in envelope_handles {
2662            handle
2663                .join()
2664                .map_err(|_| FormatError::WriterInvariant("ordered envelope worker panicked"))?;
2665        }
2666        Ok(())
2667    })?;
2668    let emit_payload = emit_payload_started.elapsed();
2669
2670    emission_state.next_block_index = ordered.next_payload_block_index;
2671    let digest = ordered.hasher.finalize();
2672    let mut content_sha256 = [0u8; 32];
2673    content_sha256.copy_from_slice(&digest);
2674    let payload = PayloadPlanning {
2675        tar_members: ordered.tar_members,
2676        frames: ordered.frames,
2677        payload_objects: ordered.payload_objects,
2678        payload_block_count: ordered.payload_block_count,
2679        tar_total_size: ordered.tar_total_size,
2680        content_sha256,
2681    };
2682    start_write_phase(progress, ArchiveWritePhase::EmittingMetadata);
2683    let plan = build_writer_plan_from_payload(
2684        payload,
2685        emission_state.next_block_index,
2686        master_key,
2687        options,
2688        None,
2689        &resolved_kdf_params,
2690        key_wrap_records,
2691        archive_uuid,
2692        session_id,
2693        root_auth,
2694    )?;
2695    if plan.options != options || plan.crypto_header != crypto_header {
2696        return Err(FormatError::WriterUnsupported(
2697            "ordered parallel metadata exceeded the predeclared header class",
2698        )
2699        .into());
2700    }
2701    let mut summary = emit_writer_plan_suffix(
2702        &subkeys,
2703        root_auth,
2704        authenticator,
2705        plan,
2706        sink,
2707        emission_state,
2708    )?;
2709    summary.timings.emit_payload += emit_payload;
2710    summary.timings.total = total_started.elapsed();
2711    Ok(summary)
2712}
2713
2714pub(crate) struct OrderedParallelArchiveWriter<'a, O: ArchiveWriteSink> {
2715    frame_job_tx: &'a std::sync::mpsc::SyncSender<OrderedFrameJob>,
2716    frame_result_rx: &'a std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2717    envelope_job_tx: &'a std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2718    envelope_result_rx:
2719        &'a std::sync::mpsc::Receiver<Result<OrderedEnvelopeResult, ArchiveWriteError>>,
2720    ordered: &'a mut OrderedParallelState,
2721    sink: &'a mut O,
2722    options: WriterOptions,
2723    emission_state: &'a mut WriterEmissionState,
2724}
2725
2726impl<O: ArchiveWriteSink> OrderedParallelArchiveWriter<'_, O> {
2727    fn reserve_member_capacity(&mut self, additional: usize) {
2728        self.ordered.tar_members.reserve(additional);
2729    }
2730
2731    pub(crate) fn write_regular_member_from_reader(
2732        &mut self,
2733        member: StreamingRegularMember,
2734        payload: &mut dyn Read,
2735    ) -> Result<(), ArchiveWriteError> {
2736        let path = &member.archive_path;
2737        validate_file_path_bytes(path, self.options.max_path_length)?;
2738        if member.entry_kind != SourceEntryKind::Regular && member.file_data_size != 0 {
2739            return Err(FormatError::WriterInvariant(
2740                "non-regular source has non-zero file data size",
2741            )
2742            .into());
2743        }
2744        let source_payload_size = member
2745            .sparse_extents
2746            .as_deref()
2747            .map(|extents| sparse_extent_bytes(extents, member.file_data_size))
2748            .transpose()?
2749            .unwrap_or(member.file_data_size);
2750        let prefix = build_primary_member_prefix(
2751            path,
2752            member.entry_kind,
2753            member.link_target.as_deref(),
2754            member.file_data_size,
2755            member.sparse_extents.as_deref(),
2756            member.mode,
2757            member.mtime,
2758            &member.portable_metadata,
2759        )?;
2760        let member_group_size = checked_u64_add(
2761            prefix.len() as u64,
2762            checked_u64_add(
2763                source_payload_size,
2764                padding_to_512_u64(source_payload_size),
2765                "tar member",
2766            )?,
2767            "tar member",
2768        )?;
2769        let mut reader = StreamingMemberReader::new(Box::new(payload), prefix, source_payload_size);
2770        self.write_prebuilt_member(member, &mut reader, member_group_size)
2771    }
2772
2773    fn write_regular_member_from_source<S: RegularFileSource + ?Sized>(
2774        &mut self,
2775        source: &S,
2776    ) -> Result<(), ArchiveWriteError> {
2777        let member = StreamingRegularMember {
2778            archive_path: normalize_lookup_file_path(
2779                source.archive_path(),
2780                self.options.max_path_length,
2781            )?,
2782            entry_kind: source.entry_kind(),
2783            link_target: source.link_target().map(<[u8]>::to_vec),
2784            file_data_size: source.file_data_size(),
2785            sparse_extents: source.sparse_extents().map(<[SparseExtent]>::to_vec),
2786            mode: source.mode(),
2787            mtime: source.mtime(),
2788            portable_metadata: source.portable_metadata(),
2789        };
2790        if member.entry_kind != SourceEntryKind::Regular && member.file_data_size != 0 {
2791            return Err(FormatError::WriterInvariant(
2792                "non-regular source has non-zero file data size",
2793            )
2794            .into());
2795        }
2796        let source_payload_size = member
2797            .sparse_extents
2798            .as_deref()
2799            .map(|extents| sparse_extent_bytes(extents, member.file_data_size))
2800            .transpose()?
2801            .unwrap_or(member.file_data_size);
2802        let layout = build_primary_member_layout(
2803            &member.archive_path,
2804            member.entry_kind,
2805            member.link_target.as_deref(),
2806            member.file_data_size,
2807            member.sparse_extents.as_deref(),
2808            member.mode,
2809            member.mtime,
2810            &member.portable_metadata,
2811        )?;
2812        let member_group_size = primary_member_layout_size(&layout, source_payload_size)?;
2813        let mut reader = StreamingMemberReader::from_source(
2814            source,
2815            &member.portable_metadata,
2816            layout,
2817            source_payload_size,
2818        )?;
2819        self.write_prebuilt_member(member, &mut reader, member_group_size)
2820    }
2821
2822    fn write_prebuilt_member(
2823        &mut self,
2824        member: StreamingRegularMember,
2825        reader: &mut StreamingMemberReader<'_>,
2826        member_group_size: u64,
2827    ) -> Result<(), ArchiveWriteError> {
2828        let member_start = self.ordered.tar_total_size;
2829        let member_index = self.ordered.tar_members.len();
2830        self.ordered.tar_members.push(TarMember {
2831            path: member.archive_path,
2832            entry_kind: member.entry_kind,
2833            link_target: member.link_target,
2834            tar_member_group_start: member_start,
2835            tar_member_group_size: member_group_size,
2836            file_data_size: member.file_data_size,
2837            sparse_extents: member.sparse_extents,
2838            mode: member.mode,
2839            mtime: member.mtime,
2840            portable_metadata: member.portable_metadata,
2841        });
2842        let mut member_offset = 0u64;
2843        while member_offset < member_group_size {
2844            let remaining = member_group_size - member_offset;
2845            let read_len = remaining.min(self.options.chunk_size as u64);
2846            let mut plaintext = vec![0u8; to_usize_writer(read_len, "payload chunk")?];
2847            reader
2848                .read_exact(&mut plaintext)
2849                .map_err(ArchiveWriteError::Io)?;
2850            self.ordered.hasher.update(&plaintext);
2851            let frame_index = self.ordered.next_frame_job_index;
2852            self.ordered.next_frame_job_index = checked_u64_add(
2853                self.ordered.next_frame_job_index,
2854                1,
2855                "PayloadFrame.frame_index",
2856            )?;
2857            send_ordered_frame_job(
2858                OrderedFrameJob {
2859                    frame_index,
2860                    member_index,
2861                    member_start,
2862                    member_offset,
2863                    member_group_size,
2864                    plaintext,
2865                },
2866                self.frame_job_tx,
2867                self.frame_result_rx,
2868                self.envelope_job_tx,
2869                self.envelope_result_rx,
2870                self.ordered,
2871                self.sink,
2872                self.options,
2873                self.emission_state,
2874            )?;
2875            member_offset = checked_u64_add(member_offset, read_len, "payload chunk")?;
2876            self.ordered.tar_total_size =
2877                checked_u64_add(self.ordered.tar_total_size, read_len, "tar stream")?;
2878        }
2879        Ok(())
2880    }
2881}
2882
2883#[allow(clippy::too_many_arguments)]
2884fn send_ordered_frame_job<O: ArchiveWriteSink>(
2885    mut job: OrderedFrameJob,
2886    frame_job_tx: &std::sync::mpsc::SyncSender<OrderedFrameJob>,
2887    frame_result_rx: &std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2888    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2889    envelope_result_rx: &std::sync::mpsc::Receiver<
2890        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2891    >,
2892    ordered: &mut OrderedParallelState,
2893    sink: &mut O,
2894    options: WriterOptions,
2895    emission_state: &mut WriterEmissionState,
2896) -> Result<(), ArchiveWriteError> {
2897    loop {
2898        match frame_job_tx.try_send(job) {
2899            Ok(()) => {
2900                drain_ordered_frame_results(
2901                    frame_result_rx,
2902                    envelope_job_tx,
2903                    envelope_result_rx,
2904                    ordered,
2905                    sink,
2906                    options,
2907                    emission_state,
2908                )?;
2909                return Ok(());
2910            }
2911            Err(std::sync::mpsc::TrySendError::Full(returned)) => {
2912                job = returned;
2913                receive_ordered_frame_result(
2914                    frame_result_rx,
2915                    envelope_job_tx,
2916                    envelope_result_rx,
2917                    ordered,
2918                    sink,
2919                    options,
2920                    emission_state,
2921                    true,
2922                )?;
2923            }
2924            Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
2925                return Err(FormatError::WriterInvariant("ordered frame worker stopped").into());
2926            }
2927        }
2928    }
2929}
2930
2931#[allow(clippy::too_many_arguments)]
2932fn drain_ordered_frame_results<O: ArchiveWriteSink>(
2933    frame_result_rx: &std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2934    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2935    envelope_result_rx: &std::sync::mpsc::Receiver<
2936        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2937    >,
2938    ordered: &mut OrderedParallelState,
2939    sink: &mut O,
2940    options: WriterOptions,
2941    emission_state: &mut WriterEmissionState,
2942) -> Result<(), ArchiveWriteError> {
2943    while receive_ordered_frame_result(
2944        frame_result_rx,
2945        envelope_job_tx,
2946        envelope_result_rx,
2947        ordered,
2948        sink,
2949        options,
2950        emission_state,
2951        false,
2952    )? {}
2953    Ok(())
2954}
2955
2956#[allow(clippy::too_many_arguments)]
2957fn receive_ordered_frame_result<O: ArchiveWriteSink>(
2958    frame_result_rx: &std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2959    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2960    envelope_result_rx: &std::sync::mpsc::Receiver<
2961        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2962    >,
2963    ordered: &mut OrderedParallelState,
2964    sink: &mut O,
2965    options: WriterOptions,
2966    emission_state: &mut WriterEmissionState,
2967    wait: bool,
2968) -> Result<bool, ArchiveWriteError> {
2969    let result = if wait {
2970        match frame_result_rx.recv() {
2971            Ok(result) => result?,
2972            Err(_) => {
2973                return Err(FormatError::WriterInvariant("ordered frame worker stopped").into())
2974            }
2975        }
2976    } else {
2977        match frame_result_rx.try_recv() {
2978            Ok(result) => result?,
2979            Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(false),
2980            Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(false),
2981        }
2982    };
2983    ordered.frame_buffer.insert(result.frame_index, result);
2984    while let Some(result) = ordered
2985        .frame_buffer
2986        .remove(&ordered.next_frame_result_index)
2987    {
2988        append_ordered_frame_result(
2989            result,
2990            envelope_job_tx,
2991            envelope_result_rx,
2992            ordered,
2993            sink,
2994            options,
2995            emission_state,
2996        )?;
2997        ordered.next_frame_result_index = checked_u64_add(
2998            ordered.next_frame_result_index,
2999            1,
3000            "PayloadFrame.frame_index",
3001        )?;
3002    }
3003    Ok(true)
3004}
3005
3006#[allow(clippy::too_many_arguments)]
3007fn append_ordered_frame_result<O: ArchiveWriteSink>(
3008    result: OrderedFrameResult,
3009    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
3010    envelope_result_rx: &std::sync::mpsc::Receiver<
3011        Result<OrderedEnvelopeResult, ArchiveWriteError>,
3012    >,
3013    ordered: &mut OrderedParallelState,
3014    sink: &mut O,
3015    options: WriterOptions,
3016    emission_state: &mut WriterEmissionState,
3017) -> Result<(), ArchiveWriteError> {
3018    if payload_envelope_needs_flush(&ordered.envelope, result.frame.len(), options)? {
3019        flush_ordered_parallel_envelope(
3020            envelope_job_tx,
3021            envelope_result_rx,
3022            ordered,
3023            sink,
3024            options,
3025            emission_state,
3026        )?;
3027    }
3028    if ordered.envelope.plaintext.is_empty()
3029        && !payload_object_can_fit(result.frame.len(), options)?
3030    {
3031        return Err(
3032            FormatError::WriterUnsupported("payload frame exceeds envelope object limits").into(),
3033        );
3034    }
3035    let offset = u32_len(
3036        ordered.envelope.plaintext.len(),
3037        "FrameEntry.offset_in_envelope",
3038    )?;
3039    ordered.envelope.plaintext.extend_from_slice(&result.frame);
3040    ordered
3041        .frames
3042        .push(payload_frame_metadata(PayloadFrameMetadataInput {
3043            frame_index: ordered.next_frame_metadata_index,
3044            envelope_index: ordered.envelope.envelope_index,
3045            member_index: result.member_index,
3046            offset_in_envelope: offset,
3047            compressed_size: result.frame.len(),
3048            decompressed_size: result.decompressed_size,
3049            member_start: result.member_start,
3050            member_offset: result.member_offset,
3051            member_group_size: result.member_group_size,
3052        })?);
3053    ordered.next_frame_metadata_index = checked_u64_add(
3054        ordered.next_frame_metadata_index,
3055        1,
3056        "PayloadFrame.frame_index",
3057    )?;
3058    Ok(())
3059}
3060
3061#[allow(clippy::too_many_arguments)]
3062fn flush_ordered_parallel_envelope<O: ArchiveWriteSink>(
3063    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
3064    envelope_result_rx: &std::sync::mpsc::Receiver<
3065        Result<OrderedEnvelopeResult, ArchiveWriteError>,
3066    >,
3067    ordered: &mut OrderedParallelState,
3068    sink: &mut O,
3069    options: WriterOptions,
3070    emission_state: &mut WriterEmissionState,
3071) -> Result<(), ArchiveWriteError> {
3072    if ordered.envelope.plaintext.is_empty() {
3073        return Ok(());
3074    }
3075    let plaintext_size = u32_len(
3076        ordered.envelope.plaintext.len(),
3077        "EnvelopeEntry.plaintext_size",
3078    )?;
3079    let object_plan = plan_encrypted_object(
3080        ordered.envelope.plaintext.len(),
3081        options.fec_data_shards,
3082        options.fec_parity_shards,
3083        options,
3084    )?;
3085    let extent = ObjectExtent::new(ordered.next_payload_block_index, object_plan)?;
3086    ordered.next_payload_block_index = extent.next_block_index()?;
3087    ordered.payload_block_count = checked_u64_add(
3088        ordered.payload_block_count,
3089        extent.data_block_count as u64,
3090        "payload",
3091    )?;
3092    ordered.payload_objects.push(PayloadObject {
3093        envelope_index: ordered.envelope.envelope_index,
3094        plaintext_size,
3095        object: extent,
3096    });
3097    let mut job = OrderedEnvelopeJob {
3098        envelope_index: ordered.envelope.envelope_index,
3099        plaintext: std::mem::take(&mut ordered.envelope.plaintext),
3100        extent,
3101        collect_data_leaf_hashes: emission_state.data_leaf_hashes.is_some(),
3102    };
3103    ordered.envelope.envelope_index =
3104        checked_u64_add(ordered.envelope.envelope_index, 1, "EnvelopeEntry")?;
3105    loop {
3106        match envelope_job_tx.try_send(job) {
3107            Ok(()) => {
3108                drain_ordered_envelope_results(
3109                    envelope_result_rx,
3110                    ordered,
3111                    sink,
3112                    options,
3113                    emission_state,
3114                )?;
3115                return Ok(());
3116            }
3117            Err(std::sync::mpsc::TrySendError::Full(returned)) => {
3118                job = returned;
3119                receive_ordered_envelope_result(
3120                    envelope_result_rx,
3121                    ordered,
3122                    sink,
3123                    options,
3124                    emission_state,
3125                    true,
3126                )?;
3127            }
3128            Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
3129                return Err(FormatError::WriterInvariant("ordered envelope worker stopped").into());
3130            }
3131        }
3132    }
3133}
3134
3135fn drain_ordered_envelope_results<O: ArchiveWriteSink>(
3136    envelope_result_rx: &std::sync::mpsc::Receiver<
3137        Result<OrderedEnvelopeResult, ArchiveWriteError>,
3138    >,
3139    ordered: &mut OrderedParallelState,
3140    sink: &mut O,
3141    options: WriterOptions,
3142    emission_state: &mut WriterEmissionState,
3143) -> Result<(), ArchiveWriteError> {
3144    while receive_ordered_envelope_result(
3145        envelope_result_rx,
3146        ordered,
3147        sink,
3148        options,
3149        emission_state,
3150        false,
3151    )? {}
3152    Ok(())
3153}
3154
3155fn receive_ordered_envelope_result<O: ArchiveWriteSink>(
3156    envelope_result_rx: &std::sync::mpsc::Receiver<
3157        Result<OrderedEnvelopeResult, ArchiveWriteError>,
3158    >,
3159    ordered: &mut OrderedParallelState,
3160    sink: &mut O,
3161    options: WriterOptions,
3162    emission_state: &mut WriterEmissionState,
3163    wait: bool,
3164) -> Result<bool, ArchiveWriteError> {
3165    let result = if wait {
3166        match envelope_result_rx.recv() {
3167            Ok(result) => result?,
3168            Err(_) => {
3169                return Err(FormatError::WriterInvariant("ordered envelope worker stopped").into());
3170            }
3171        }
3172    } else {
3173        match envelope_result_rx.try_recv() {
3174            Ok(result) => result?,
3175            Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(false),
3176            Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(false),
3177        }
3178    };
3179    ordered
3180        .envelope_buffer
3181        .insert(result.envelope_index, result);
3182    while let Some(result) = ordered
3183        .envelope_buffer
3184        .remove(&ordered.next_envelope_result_index)
3185    {
3186        emit_ordered_envelope_result(result, sink, options, emission_state)?;
3187        ordered.next_envelope_result_index =
3188            checked_u64_add(ordered.next_envelope_result_index, 1, "EnvelopeEntry")?;
3189    }
3190    Ok(true)
3191}
3192
3193fn build_ordered_frame_result(
3194    job: OrderedFrameJob,
3195    options: WriterOptions,
3196) -> Result<OrderedFrameResult, ArchiveWriteError> {
3197    let frame = compress_zstd_frame_with_jobs(&job.plaintext, options.zstd_level, 1)?;
3198    Ok(OrderedFrameResult {
3199        frame_index: job.frame_index,
3200        member_index: job.member_index,
3201        member_start: job.member_start,
3202        member_offset: job.member_offset,
3203        member_group_size: job.member_group_size,
3204        decompressed_size: job.plaintext.len(),
3205        frame,
3206    })
3207}
3208
3209fn build_ordered_envelope_result(
3210    job: OrderedEnvelopeJob,
3211    subkeys: &Subkeys,
3212    options: WriterOptions,
3213    archive_uuid: [u8; 16],
3214    session_id: [u8; 16],
3215) -> Result<OrderedEnvelopeResult, ArchiveWriteError> {
3216    let context = ObjectEncryptionContext {
3217        key: &subkeys.enc_key,
3218        nonce_seed: &subkeys.nonce_seed,
3219        domain: b"envelope",
3220        counter: job.envelope_index,
3221        data_kind: BlockKind::PayloadData,
3222        parity_kind: BlockKind::PayloadParity,
3223        data_shard_max: options.fec_data_shards,
3224        class_parity_shard_max: options.fec_parity_shards,
3225        archive_uuid: &archive_uuid,
3226        session_id: &session_id,
3227    };
3228    if job.extent.parity_block_count == 0 && !job.collect_data_leaf_hashes {
3229        return Ok(OrderedEnvelopeResult {
3230            envelope_index: job.envelope_index,
3231            records: OrderedEnvelopeRecords::Serialized(serialize_zero_parity_encrypted_object(
3232                &job.plaintext,
3233                context,
3234                job.extent,
3235                options,
3236            )?),
3237        });
3238    }
3239
3240    let mut local_next_block_index = job.extent.first_block_index;
3241    let object = encrypt_object(
3242        &job.plaintext,
3243        context,
3244        &mut local_next_block_index,
3245        options,
3246    )?;
3247    validate_planned_extent(&object, job.extent)?;
3248    Ok(OrderedEnvelopeResult {
3249        envelope_index: job.envelope_index,
3250        records: OrderedEnvelopeRecords::Materialized(object.records),
3251    })
3252}
3253
3254fn emit_ordered_envelope_result<O: ArchiveWriteSink>(
3255    result: OrderedEnvelopeResult,
3256    sink: &mut O,
3257    options: WriterOptions,
3258    emission_state: &mut WriterEmissionState,
3259) -> Result<(), ArchiveWriteError> {
3260    match result.records {
3261        OrderedEnvelopeRecords::Materialized(records) => {
3262            for record in &records {
3263                emit_block_record(
3264                    sink,
3265                    options,
3266                    &mut emission_state.bytes_written,
3267                    &mut emission_state.record_counts,
3268                    emission_state.volume_format_rev,
3269                    &mut emission_state.data_leaf_hashes,
3270                    record,
3271                )?;
3272            }
3273        }
3274        OrderedEnvelopeRecords::Serialized(records) => {
3275            for record in &records {
3276                emit_serialized_block_record(
3277                    sink,
3278                    options,
3279                    &mut emission_state.bytes_written,
3280                    &mut emission_state.record_counts,
3281                    record,
3282                )?;
3283            }
3284        }
3285    }
3286    Ok(())
3287}
3288
3289fn validate_single_pass_writer_options(options: WriterOptions) -> Result<(), FormatError> {
3290    if options.volume_loss_tolerance != 0 {
3291        return Err(FormatError::WriterUnsupported(
3292            "streaming create cannot tolerate volume loss",
3293        ));
3294    }
3295    if options.target_volume_size.is_some() {
3296        return Err(FormatError::WriterUnsupported(
3297            "streaming create does not support target volume sizing",
3298        ));
3299    }
3300    Ok(())
3301}
3302
3303#[allow(clippy::too_many_arguments)]
3304fn begin_writer_emission_state<O: ArchiveWriteSink>(
3305    sink: &mut O,
3306    options: WriterOptions,
3307    crypto_header: &[u8],
3308    key_wrap_table: Option<&[u8]>,
3309    archive_uuid: [u8; 16],
3310    session_id: [u8; 16],
3311    volume_format_rev: u16,
3312    collect_data_leaf_hashes: bool,
3313) -> Result<WriterEmissionState, ArchiveWriteError> {
3314    let volume_count = usize::try_from(options.stripe_width)
3315        .map_err(|_| FormatError::WriterUnsupported("stripe_width"))?;
3316    sink.begin_archive(volume_count)?;
3317
3318    let mut state = WriterEmissionState {
3319        volume_headers: Vec::with_capacity(volume_count),
3320        bytes_written: vec![0u64; volume_count],
3321        record_counts: vec![0u64; volume_count],
3322        volume_format_rev,
3323        data_leaf_hashes: collect_data_leaf_hashes.then(Vec::new),
3324        next_block_index: 0,
3325    };
3326
3327    for volume_index in 0..volume_count {
3328        let volume_index_u32 = u32::try_from(volume_index)
3329            .map_err(|_| FormatError::WriterUnsupported("volume_index"))?;
3330        let volume_header = VolumeHeader {
3331            format_version: FORMAT_VERSION,
3332            volume_format_rev,
3333            volume_index: volume_index_u32,
3334            stripe_width: options.stripe_width,
3335            archive_uuid,
3336            session_id,
3337            crypto_header_offset: VOLUME_HEADER_LEN as u32,
3338            crypto_header_length: u32_len(crypto_header.len(), "CryptoHeader")?,
3339            header_crc32c: 0,
3340        };
3341        let volume_header_bytes = volume_header.to_bytes();
3342        sink.write_volume(volume_index, &volume_header_bytes)?;
3343        sink.write_volume(volume_index, crypto_header)?;
3344        let mut bytes_written = checked_u64_add(
3345            VOLUME_HEADER_LEN as u64,
3346            crypto_header.len() as u64,
3347            "volume header",
3348        )?;
3349        if let Some(key_wrap_table) = key_wrap_table {
3350            sink.write_volume(volume_index, key_wrap_table)?;
3351            bytes_written =
3352                checked_u64_add(bytes_written, key_wrap_table.len() as u64, "KeyWrapTableV1")?;
3353        }
3354        state.bytes_written[volume_index] = bytes_written;
3355        state.volume_headers.push(volume_header_bytes);
3356    }
3357
3358    Ok(state)
3359}
3360
3361fn plan_single_pass_writer_options(options: WriterOptions) -> Result<WriterOptions, FormatError> {
3362    let mut options = plan_writer_options(options)?;
3363    options.index_root_fec_data_shards = max_single_pass_index_root_data_shards(options)?;
3364    plan_writer_options(options)
3365}
3366
3367fn max_single_pass_index_root_data_shards(options: WriterOptions) -> Result<u16, FormatError> {
3368    let block_size_limit = (u32::MAX as u64 / options.block_size as u64).min(u16::MAX as u64);
3369    let mut low = MIN_INDEX_ROOT_FEC_DATA_SHARDS as u64;
3370    let mut high = block_size_limit;
3371    let mut best = low;
3372    while low <= high {
3373        let mid = low + (high - low) / 2;
3374        match compute_parity(mid, options) {
3375            Ok(parity)
3376                if mid + u64::from(parity) <= READER_MAX_INDEX_ROOT_FEC_CLASS_SHARDS as u64 =>
3377            {
3378                best = mid;
3379                low = mid + 1;
3380            }
3381            _ => {
3382                if mid == 0 {
3383                    break;
3384                }
3385                high = mid - 1;
3386            }
3387        }
3388    }
3389    u16::try_from(best).map_err(|_| FormatError::WriterUnsupported("index_root_fec_data_shards"))
3390}
3391
3392impl<O: ArchiveWriteSink> StreamingArchiveWriter<'_, O> {
3393    fn write_regular_member_from_source<S: RegularFileSource + ?Sized>(
3394        &mut self,
3395        source: &S,
3396    ) -> Result<(), ArchiveWriteError> {
3397        let member = StreamingRegularMember {
3398            archive_path: normalize_lookup_file_path(
3399                source.archive_path(),
3400                self.options.max_path_length,
3401            )?,
3402            entry_kind: source.entry_kind(),
3403            link_target: source.link_target().map(<[u8]>::to_vec),
3404            file_data_size: source.file_data_size(),
3405            sparse_extents: source.sparse_extents().map(<[SparseExtent]>::to_vec),
3406            mode: source.mode(),
3407            mtime: source.mtime(),
3408            portable_metadata: source.portable_metadata(),
3409        };
3410        if member.entry_kind != SourceEntryKind::Regular && member.file_data_size != 0 {
3411            return Err(FormatError::WriterInvariant(
3412                "non-regular source has non-zero file data size",
3413            )
3414            .into());
3415        }
3416        let source_payload_size = member
3417            .sparse_extents
3418            .as_deref()
3419            .map(|extents| sparse_extent_bytes(extents, member.file_data_size))
3420            .transpose()?
3421            .unwrap_or(member.file_data_size);
3422        let layout = build_primary_member_layout(
3423            &member.archive_path,
3424            member.entry_kind,
3425            member.link_target.as_deref(),
3426            member.file_data_size,
3427            member.sparse_extents.as_deref(),
3428            member.mode,
3429            member.mtime,
3430            &member.portable_metadata,
3431        )?;
3432        let member_group_size = primary_member_layout_size(&layout, source_payload_size)?;
3433        let mut reader = StreamingMemberReader::from_source(
3434            source,
3435            &member.portable_metadata,
3436            layout,
3437            source_payload_size,
3438        )?;
3439        self.write_prebuilt_member(member, &mut reader, member_group_size)
3440    }
3441
3442    fn write_prebuilt_member(
3443        &mut self,
3444        member: StreamingRegularMember,
3445        reader: &mut StreamingMemberReader<'_>,
3446        member_group_size: u64,
3447    ) -> Result<(), ArchiveWriteError> {
3448        let member_start = self.tar_total_size;
3449        let member_index = self.tar_members.len();
3450        self.tar_members.push(TarMember {
3451            path: member.archive_path,
3452            entry_kind: member.entry_kind,
3453            link_target: member.link_target,
3454            tar_member_group_start: member_start,
3455            tar_member_group_size: member_group_size,
3456            file_data_size: member.file_data_size,
3457            sparse_extents: member.sparse_extents,
3458            mode: member.mode,
3459            mtime: member.mtime,
3460            portable_metadata: member.portable_metadata,
3461        });
3462        let mut member_offset = 0u64;
3463        while member_offset < member_group_size {
3464            let remaining = member_group_size - member_offset;
3465            let max_chunk = remaining.min(self.options.chunk_size as u64);
3466            let mut chunk = vec![0u8; to_usize_writer(max_chunk, "payload chunk")?];
3467            reader
3468                .read_exact(&mut chunk)
3469                .map_err(ArchiveWriteError::Io)?;
3470            let mut chunk_len = chunk.len();
3471            let frame = loop {
3472                let candidate = &chunk[..chunk_len];
3473                let frame = compress_zstd_frame_with_jobs(
3474                    candidate,
3475                    self.options.zstd_level,
3476                    self.options.jobs,
3477                )?;
3478                if payload_object_can_fit(frame.len(), self.options)? {
3479                    break frame;
3480                }
3481                if chunk_len == 1 {
3482                    return Err(FormatError::WriterUnsupported(
3483                        "single-byte payload frame exceeds envelope object limits",
3484                    )
3485                    .into());
3486                }
3487                chunk_len = (chunk_len / 2).max(1);
3488            };
3489            if chunk_len < chunk.len() {
3490                reader.push_back(chunk[chunk_len..].to_vec());
3491            }
3492            let chunk = &chunk[..chunk_len];
3493            self.hasher.update(chunk);
3494            self.append_payload_frame(
3495                &frame,
3496                chunk_len,
3497                member_index,
3498                member_start,
3499                member_offset,
3500                member_group_size,
3501            )?;
3502            member_offset = checked_u64_add(member_offset, chunk_len as u64, "payload chunk")?;
3503            self.tar_total_size =
3504                checked_u64_add(self.tar_total_size, chunk_len as u64, "tar stream")?;
3505        }
3506        Ok(())
3507    }
3508
3509    fn append_payload_frame(
3510        &mut self,
3511        frame: &[u8],
3512        decompressed_size: usize,
3513        member_index: usize,
3514        member_start: u64,
3515        member_offset: u64,
3516        member_group_size: u64,
3517    ) -> Result<(), ArchiveWriteError> {
3518        if payload_envelope_needs_flush(&self.envelope, frame.len(), self.options)? {
3519            self.flush_payload_envelope()?;
3520        }
3521        if self.envelope.plaintext.is_empty() && !payload_object_can_fit(frame.len(), self.options)?
3522        {
3523            return Err(FormatError::WriterUnsupported(
3524                "payload frame exceeds envelope object limits",
3525            )
3526            .into());
3527        }
3528        let offset = u32_len(
3529            self.envelope.plaintext.len(),
3530            "FrameEntry.offset_in_envelope",
3531        )?;
3532        self.envelope.plaintext.extend_from_slice(frame);
3533        self.frames
3534            .push(payload_frame_metadata(PayloadFrameMetadataInput {
3535                frame_index: self.next_frame_index,
3536                envelope_index: self.envelope.envelope_index,
3537                member_index,
3538                offset_in_envelope: offset,
3539                compressed_size: frame.len(),
3540                decompressed_size,
3541                member_start,
3542                member_offset,
3543                member_group_size,
3544            })?);
3545        self.next_frame_index =
3546            checked_u64_add(self.next_frame_index, 1, "PayloadFrame.frame_index")?;
3547        Ok(())
3548    }
3549
3550    fn flush_payload_envelope(&mut self) -> Result<(), ArchiveWriteError> {
3551        if self.envelope.plaintext.is_empty() {
3552            return Ok(());
3553        }
3554        let plaintext_size = u32_len(
3555            self.envelope.plaintext.len(),
3556            "EnvelopeEntry.plaintext_size",
3557        )?;
3558        let object_plan = plan_encrypted_object(
3559            self.envelope.plaintext.len(),
3560            self.options.fec_data_shards,
3561            self.options.fec_parity_shards,
3562            self.options,
3563        )?;
3564        let extent = ObjectExtent::new(self.emission_state.next_block_index, object_plan)?;
3565        let object = encrypt_object(
3566            &self.envelope.plaintext,
3567            ObjectEncryptionContext {
3568                key: &self.subkeys.enc_key,
3569                nonce_seed: &self.subkeys.nonce_seed,
3570                domain: b"envelope",
3571                counter: self.envelope.envelope_index,
3572                data_kind: BlockKind::PayloadData,
3573                parity_kind: BlockKind::PayloadParity,
3574                data_shard_max: self.options.fec_data_shards,
3575                class_parity_shard_max: self.options.fec_parity_shards,
3576                archive_uuid: &self.archive_uuid,
3577                session_id: &self.session_id,
3578            },
3579            &mut self.emission_state.next_block_index,
3580            self.options,
3581        )?;
3582        validate_planned_extent(&object, extent)?;
3583        for record in &object.records {
3584            emit_block_record(
3585                self.sink,
3586                self.options,
3587                &mut self.emission_state.bytes_written,
3588                &mut self.emission_state.record_counts,
3589                self.emission_state.volume_format_rev,
3590                &mut self.emission_state.data_leaf_hashes,
3591                record,
3592            )?;
3593        }
3594        self.payload_block_count = checked_u64_add(
3595            self.payload_block_count,
3596            extent.data_block_count as u64,
3597            "payload",
3598        )?;
3599        self.payload_objects.push(PayloadObject {
3600            envelope_index: self.envelope.envelope_index,
3601            plaintext_size,
3602            object: extent,
3603        });
3604        self.envelope.envelope_index =
3605            checked_u64_add(self.envelope.envelope_index, 1, "EnvelopeEntry")?;
3606        self.envelope.plaintext.clear();
3607        Ok(())
3608    }
3609
3610    fn finish(
3611        mut self,
3612        master_key: &MasterKey,
3613        kdf_params: &KdfParams,
3614        key_wrap_records: Option<&KeyWrapRecordSource>,
3615        root_auth: Option<RootAuthWriterConfig<'_>>,
3616        authenticator: Option<&mut RootAuthAuthenticator<'_>>,
3617        progress: Option<&SourceProgressHandle<'_>>,
3618    ) -> Result<WrittenArchiveSummary, ArchiveWriteError> {
3619        self.flush_payload_envelope()?;
3620        let digest = self.hasher.finalize();
3621        let mut content_sha256 = [0u8; 32];
3622        content_sha256.copy_from_slice(&digest);
3623        let payload = PayloadPlanning {
3624            tar_members: self.tar_members,
3625            frames: self.frames,
3626            payload_objects: self.payload_objects,
3627            payload_block_count: self.payload_block_count,
3628            tar_total_size: self.tar_total_size,
3629            content_sha256,
3630        };
3631        start_write_phase(progress, ArchiveWritePhase::EmittingMetadata);
3632        let plan = build_writer_plan_from_payload(
3633            payload,
3634            self.emission_state.next_block_index,
3635            master_key,
3636            self.options,
3637            None,
3638            kdf_params,
3639            key_wrap_records,
3640            self.archive_uuid,
3641            self.session_id,
3642            root_auth,
3643        )?;
3644        if plan.options != self.options || plan.crypto_header != self.crypto_header {
3645            return Err(FormatError::WriterUnsupported(
3646                "streaming tar stdin metadata exceeded the predeclared header class",
3647            )
3648            .into());
3649        }
3650        emit_writer_plan_suffix(
3651            &self.subkeys,
3652            root_auth,
3653            authenticator,
3654            plan,
3655            self.sink,
3656            self.emission_state,
3657        )
3658    }
3659}
3660
3661struct PayloadFramePlanState<'a> {
3662    envelope: &'a mut PayloadEnvelopeBuilder,
3663    payload_objects: &'a mut Vec<PayloadObject>,
3664    payload_block_count: &'a mut u64,
3665    next_block_index: &'a mut u64,
3666    frames: &'a mut Vec<PayloadFrame>,
3667    next_frame_index: &'a mut u64,
3668    options: WriterOptions,
3669}
3670
3671struct PayloadFramePlanInput<'a> {
3672    frame: &'a [u8],
3673    decompressed_size: usize,
3674    member_index: usize,
3675    member_start: u64,
3676    member_offset: u64,
3677    member_group_size: u64,
3678}
3679
3680fn append_payload_frame_to_plan(
3681    state: PayloadFramePlanState<'_>,
3682    input: PayloadFramePlanInput<'_>,
3683) -> Result<(), FormatError> {
3684    if payload_envelope_needs_flush(state.envelope, input.frame.len(), state.options)? {
3685        flush_payload_envelope_plan(
3686            state.envelope,
3687            state.payload_objects,
3688            state.payload_block_count,
3689            state.next_block_index,
3690            state.options,
3691        )?;
3692    }
3693    if state.envelope.plaintext.is_empty()
3694        && !payload_object_can_fit(input.frame.len(), state.options)?
3695    {
3696        return Err(FormatError::WriterUnsupported(
3697            "payload frame exceeds envelope object limits",
3698        ));
3699    }
3700    let offset = u32_len(
3701        state.envelope.plaintext.len(),
3702        "FrameEntry.offset_in_envelope",
3703    )?;
3704    state.envelope.plaintext.extend_from_slice(input.frame);
3705    state
3706        .frames
3707        .push(payload_frame_metadata(PayloadFrameMetadataInput {
3708            frame_index: *state.next_frame_index,
3709            envelope_index: state.envelope.envelope_index,
3710            member_index: input.member_index,
3711            offset_in_envelope: offset,
3712            compressed_size: input.frame.len(),
3713            decompressed_size: input.decompressed_size,
3714            member_start: input.member_start,
3715            member_offset: input.member_offset,
3716            member_group_size: input.member_group_size,
3717        })?);
3718    *state.next_frame_index =
3719        checked_u64_add(*state.next_frame_index, 1, "PayloadFrame.frame_index")?;
3720    Ok(())
3721}
3722
3723fn flush_payload_envelope_plan(
3724    envelope: &mut PayloadEnvelopeBuilder,
3725    payload_objects: &mut Vec<PayloadObject>,
3726    payload_block_count: &mut u64,
3727    next_block_index: &mut u64,
3728    options: WriterOptions,
3729) -> Result<(), FormatError> {
3730    let plaintext_size = u32_len(envelope.plaintext.len(), "EnvelopeEntry.plaintext_size")?;
3731    let object_plan = plan_encrypted_object(
3732        envelope.plaintext.len(),
3733        options.fec_data_shards,
3734        options.fec_parity_shards,
3735        options,
3736    )?;
3737    let extent = ObjectExtent::new(*next_block_index, object_plan)?;
3738    *next_block_index = extent.next_block_index()?;
3739    *payload_block_count = checked_u64_add(
3740        *payload_block_count,
3741        extent.data_block_count as u64,
3742        "payload",
3743    )?;
3744    payload_objects.push(PayloadObject {
3745        envelope_index: envelope.envelope_index,
3746        plaintext_size,
3747        object: extent,
3748    });
3749    envelope.envelope_index = checked_u64_add(envelope.envelope_index, 1, "EnvelopeEntry")?;
3750    envelope.plaintext.clear();
3751    Ok(())
3752}
3753
3754fn required_stripe_width_for_plan(
3755    plan: &WriterPlan,
3756    master_key: &MasterKey,
3757    target_volume_size: u64,
3758) -> Result<u32, FormatError> {
3759    let subkeys = writer_subkeys(
3760        master_key,
3761        plan.options.aead_algo,
3762        &plan.archive_uuid,
3763        &plan.session_id,
3764    )?;
3765    let mut max_volume_size = 0u64;
3766    let mut max_overhead = 0u64;
3767    let block_record_len = plan.options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
3768    for volume_index in 0..plan.options.stripe_width {
3769        let block_count = striped_block_count(
3770            plan.total_block_count,
3771            plan.options.stripe_width,
3772            volume_index,
3773        );
3774        let volume_size = planned_v41_volume_size(plan, &subkeys, volume_index, block_count)?;
3775        max_volume_size = max_volume_size.max(volume_size);
3776        let record_bytes = checked_u64_mul(block_count, block_record_len, "volume records")?;
3777        let overhead =
3778            volume_size
3779                .checked_sub(record_bytes)
3780                .ok_or(FormatError::WriterInvariant(
3781                    "planned volume record overflow",
3782                ))?;
3783        max_overhead = max_overhead.max(overhead);
3784    }
3785    if max_volume_size <= target_volume_size {
3786        return Ok(plan.options.stripe_width);
3787    }
3788    if target_volume_size <= max_overhead {
3789        return Err(FormatError::WriterUnsupported(
3790            "volume-size is too small for per-volume metadata",
3791        ));
3792    }
3793
3794    let records_per_volume = (target_volume_size - max_overhead) / block_record_len;
3795    if records_per_volume == 0 {
3796        return Err(FormatError::WriterUnsupported(
3797            "volume-size is too small for the configured block-size",
3798        ));
3799    }
3800
3801    let required = ceil_div(plan.total_block_count, records_per_volume)?
3802        .max(plan.options.volume_loss_tolerance as u64 + 1)
3803        .max(1);
3804    u32::try_from(required).map_err(|_| FormatError::WriterUnsupported("volume count"))
3805}
3806
3807fn planned_v41_volume_size(
3808    plan: &WriterPlan,
3809    subkeys: &Subkeys,
3810    volume_index: u32,
3811    block_count: u64,
3812) -> Result<u64, FormatError> {
3813    let volume_header = VolumeHeader {
3814        format_version: FORMAT_VERSION,
3815        volume_format_rev: plan.volume_format_rev,
3816        volume_index,
3817        stripe_width: plan.options.stripe_width,
3818        archive_uuid: plan.archive_uuid,
3819        session_id: plan.session_id,
3820        crypto_header_offset: VOLUME_HEADER_LEN as u32,
3821        crypto_header_length: u32_len(plan.crypto_header.len(), "CryptoHeader")?,
3822        header_crc32c: 0,
3823    };
3824    let volume_header_bytes = volume_header.to_bytes();
3825    let block_record_len = plan.options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
3826    let block_record_bytes = checked_u64_mul(block_count, block_record_len, "volume records")?;
3827    let manifest_footer_offset = checked_u64_add(
3828        plan.block_records_offset,
3829        block_record_bytes,
3830        "volume records",
3831    )?;
3832    let manifest_footer = build_manifest_footer(
3833        subkeys,
3834        plan.options.aead_algo,
3835        plan.volume_format_rev,
3836        plan.archive_uuid,
3837        plan.session_id,
3838        volume_index,
3839        plan.options.stripe_width,
3840        &plan.index_root_extent,
3841        plan.index_root_plaintext.len(),
3842    )?;
3843    let root_auth_footer = plan
3844        .root_auth_footer_length
3845        .map(|length| vec![0u8; length as usize]);
3846    let root_auth_footer_offset = root_auth_footer
3847        .as_ref()
3848        .map(|_| {
3849            checked_u64_add(
3850                manifest_footer_offset,
3851                MANIFEST_FOOTER_LEN as u64,
3852                "RootAuthFooterV1",
3853            )
3854        })
3855        .transpose()?;
3856    let trailer_offset = checked_u64_add(
3857        manifest_footer_offset,
3858        MANIFEST_FOOTER_LEN as u64 + u64::from(plan.root_auth_footer_length.unwrap_or(0)),
3859        "VolumeTrailer",
3860    )?;
3861    let trailer = build_volume_trailer(VolumeTrailerBuildInput {
3862        subkeys,
3863        aead_algo: plan.options.aead_algo,
3864        volume_format_rev: plan.volume_format_rev,
3865        archive_uuid: plan.archive_uuid,
3866        session_id: plan.session_id,
3867        volume_index,
3868        block_count,
3869        bytes_written: trailer_offset,
3870        manifest_footer_offset,
3871        closed_at_ns: plan.options.closed_at_ns,
3872        root_auth_footer: root_auth_footer_offset.zip(plan.root_auth_footer_length),
3873    })?;
3874    let cmra_offset = checked_u64_add(trailer_offset, VOLUME_TRAILER_LEN as u64, "CMRA")?;
3875    let cmra = build_v41_cmra(CmraBuildInput {
3876        volume_format_rev: plan.volume_format_rev,
3877        volume_header_bytes: &volume_header_bytes,
3878        crypto_header: &plan.crypto_header,
3879        block_count,
3880        block_records_offset: plan.block_records_offset,
3881        manifest_footer_offset,
3882        manifest_footer: &manifest_footer,
3883        root_auth_footer_offset,
3884        root_auth_footer: root_auth_footer.as_deref(),
3885        key_wrap_table: plan.key_wrap_table.as_deref(),
3886        trailer_offset,
3887        trailer: &trailer,
3888        cmra_offset,
3889        options: plan.options,
3890        archive_uuid: plan.archive_uuid,
3891        session_id: plan.session_id,
3892        volume_index,
3893    })?;
3894    checked_u64_add(
3895        checked_u64_add(cmra_offset, cmra.bytes.len() as u64, "CMRA")?,
3896        (CRITICAL_RECOVERY_LOCATOR_LEN * 2) as u64,
3897        "critical recovery locators",
3898    )
3899}
3900
3901fn striped_block_count(total_block_count: u64, stripe_width: u32, volume_index: u32) -> u64 {
3902    let volume_index = volume_index as u64;
3903    let stripe_width = stripe_width as u64;
3904    if total_block_count <= volume_index {
3905        0
3906    } else {
3907        (total_block_count - 1 - volume_index) / stripe_width + 1
3908    }
3909}
3910
3911#[allow(clippy::too_many_arguments)]
3912fn emit_writer_plan<S, O>(
3913    files: &[S],
3914    master_key: &MasterKey,
3915    dictionary: Option<&[u8]>,
3916    root_auth: Option<RootAuthWriterConfig<'_>>,
3917    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
3918    plan: WriterPlan,
3919    sink: &mut O,
3920    progress: Option<&SourceProgressHandle<'_>>,
3921) -> Result<WrittenArchiveSummary, ArchiveWriteError>
3922where
3923    S: RegularFileSource,
3924    O: ArchiveWriteSink,
3925{
3926    let subkeys = writer_subkeys(
3927        master_key,
3928        plan.options.aead_algo,
3929        &plan.archive_uuid,
3930        &plan.session_id,
3931    )?;
3932    let mut state = begin_writer_emission_state(
3933        sink,
3934        plan.options,
3935        &plan.crypto_header,
3936        plan.key_wrap_table.as_deref(),
3937        plan.archive_uuid,
3938        plan.session_id,
3939        plan.volume_format_rev,
3940        root_auth.is_some(),
3941    )?;
3942
3943    let emit_payload_started = Instant::now();
3944    emit_payload_stream(
3945        files,
3946        dictionary,
3947        &subkeys,
3948        &plan,
3949        &mut state.next_block_index,
3950        sink,
3951        &mut state.bytes_written,
3952        &mut state.record_counts,
3953        &mut state.data_leaf_hashes,
3954    )?;
3955    let emit_payload = emit_payload_started.elapsed();
3956
3957    start_write_phase(progress, ArchiveWritePhase::EmittingMetadata);
3958    let mut summary =
3959        emit_writer_plan_suffix(&subkeys, root_auth, authenticator, plan, sink, state)?;
3960    summary.timings.emit_payload += emit_payload;
3961    Ok(summary)
3962}
3963
3964fn start_write_phase(progress: Option<&SourceProgressHandle<'_>>, phase: ArchiveWritePhase) {
3965    if let Some(progress) = progress {
3966        progress.borrow_mut().start_phase(phase);
3967    }
3968}
3969
3970fn emit_writer_plan_suffix<O: ArchiveWriteSink>(
3971    subkeys: &Subkeys,
3972    root_auth: Option<RootAuthWriterConfig<'_>>,
3973    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
3974    plan: WriterPlan,
3975    sink: &mut O,
3976    mut state: WriterEmissionState,
3977) -> Result<WrittenArchiveSummary, ArchiveWriteError> {
3978    let emit_metadata_started = Instant::now();
3979    let volume_count = plan.options.stripe_width as usize;
3980
3981    for planned in &plan.index_shard_objects {
3982        emit_encrypted_object(
3983            &planned.compressed,
3984            &subkeys.index_shard_key,
3985            &subkeys.index_nonce_seed,
3986            b"idxshard",
3987            planned.shard_index,
3988            BlockKind::IndexShardData,
3989            BlockKind::IndexShardParity,
3990            plan.options.index_fec_data_shards,
3991            plan.options.index_fec_parity_shards,
3992            &mut state.next_block_index,
3993            plan.options,
3994            &plan.archive_uuid,
3995            &plan.session_id,
3996            planned.extent,
3997            None,
3998            plan.volume_format_rev,
3999            sink,
4000            &mut state.bytes_written,
4001            &mut state.record_counts,
4002            &mut state.data_leaf_hashes,
4003        )?;
4004    }
4005
4006    let dictionary_records = if let (Some(compressed), Some((extent, _))) =
4007        (plan.compressed_dictionary.as_ref(), plan.dictionary_extent)
4008    {
4009        let object = emit_encrypted_object(
4010            compressed,
4011            &subkeys.dictionary_key,
4012            &subkeys.index_nonce_seed,
4013            b"dict",
4014            0,
4015            BlockKind::DictionaryData,
4016            BlockKind::DictionaryParity,
4017            plan.options.index_root_fec_data_shards,
4018            plan.options.index_root_fec_parity_shards,
4019            &mut state.next_block_index,
4020            plan.options,
4021            &plan.archive_uuid,
4022            &plan.session_id,
4023            extent,
4024            Some(MetadataObjectKind::Dictionary),
4025            plan.volume_format_rev,
4026            sink,
4027            &mut state.bytes_written,
4028            &mut state.record_counts,
4029            &mut state.data_leaf_hashes,
4030        )?;
4031        Some(object.records)
4032    } else {
4033        None
4034    };
4035
4036    for planned in &plan.directory_hint_objects {
4037        emit_encrypted_object(
4038            &planned.compressed,
4039            &subkeys.dir_hint_key,
4040            &subkeys.index_nonce_seed,
4041            b"dirhint",
4042            planned.hint_shard_index,
4043            BlockKind::DirectoryHintData,
4044            BlockKind::DirectoryHintParity,
4045            plan.options.index_fec_data_shards,
4046            plan.options.index_fec_parity_shards,
4047            &mut state.next_block_index,
4048            plan.options,
4049            &plan.archive_uuid,
4050            &plan.session_id,
4051            planned.extent,
4052            None,
4053            plan.volume_format_rev,
4054            sink,
4055            &mut state.bytes_written,
4056            &mut state.record_counts,
4057            &mut state.data_leaf_hashes,
4058        )?;
4059    }
4060
4061    let index_root_object = emit_encrypted_object(
4062        &plan.compressed_index_root,
4063        &subkeys.index_root_key,
4064        &subkeys.index_nonce_seed,
4065        b"idxroot",
4066        0,
4067        BlockKind::IndexRootData,
4068        BlockKind::IndexRootParity,
4069        plan.options.index_root_fec_data_shards,
4070        plan.options.index_root_fec_parity_shards,
4071        &mut state.next_block_index,
4072        plan.options,
4073        &plan.archive_uuid,
4074        &plan.session_id,
4075        plan.index_root_extent,
4076        Some(MetadataObjectKind::IndexRoot),
4077        plan.volume_format_rev,
4078        sink,
4079        &mut state.bytes_written,
4080        &mut state.record_counts,
4081        &mut state.data_leaf_hashes,
4082    )?;
4083    if state.next_block_index != plan.total_block_count {
4084        return Err(FormatError::WriterInvariant("streaming writer block plan mismatch").into());
4085    }
4086
4087    let volume_zero_manifest = build_manifest_footer(
4088        subkeys,
4089        plan.options.aead_algo,
4090        plan.volume_format_rev,
4091        plan.archive_uuid,
4092        plan.session_id,
4093        0,
4094        plan.options.stripe_width,
4095        &plan.index_root_extent,
4096        plan.index_root_plaintext.len(),
4097    )?;
4098    let root_auth_footer = match root_auth {
4099        Some(config) => {
4100            let signer = authenticator.ok_or(FormatError::WriterInvariant(
4101                "missing root-auth authenticator",
4102            ))?;
4103            Some(build_root_auth_footer_from_leaf_hashes(
4104                config,
4105                signer,
4106                RootAuthFooterBuildInput {
4107                    archive_uuid: plan.archive_uuid,
4108                    session_id: plan.session_id,
4109                    volume_format_rev: plan.volume_format_rev,
4110                    options: plan.options,
4111                    crypto_header: &plan.crypto_header,
4112                    volume_zero_manifest: &volume_zero_manifest,
4113                    index_root_plaintext: &plan.index_root_plaintext,
4114                    index_root_extent: plan.index_root_extent,
4115                    dictionary_extent: plan.dictionary_extent,
4116                    shard_entries: &plan.shard_entries,
4117                    payload_objects: &plan.payload_objects,
4118                    directory_hint_entries: &plan.directory_hint_entries,
4119                    data_leaf_hashes: state.data_leaf_hashes.as_deref().ok_or(
4120                        FormatError::WriterInvariant("missing root-auth data leaf hashes"),
4121                    )?,
4122                },
4123            )?)
4124        }
4125        None => None,
4126    };
4127    let root_auth_footer_length = root_auth_footer
4128        .as_ref()
4129        .map(|footer| u32_len(footer.len(), "RootAuthFooterV1"))
4130        .transpose()?;
4131
4132    for volume_index in 0..volume_count {
4133        let volume_index_u32 = u32::try_from(volume_index)
4134            .map_err(|_| FormatError::WriterUnsupported("volume_index"))?;
4135        let manifest_footer_offset = state.bytes_written[volume_index];
4136        let manifest_footer = build_manifest_footer(
4137            subkeys,
4138            plan.options.aead_algo,
4139            plan.volume_format_rev,
4140            plan.archive_uuid,
4141            plan.session_id,
4142            volume_index_u32,
4143            plan.options.stripe_width,
4144            &plan.index_root_extent,
4145            plan.index_root_plaintext.len(),
4146        )?;
4147        sink.write_volume(volume_index, &manifest_footer)?;
4148        state.bytes_written[volume_index] = checked_u64_add(
4149            state.bytes_written[volume_index],
4150            MANIFEST_FOOTER_LEN as u64,
4151            "ManifestFooter",
4152        )?;
4153
4154        let root_auth_footer_offset = if let Some(root_auth_footer) = root_auth_footer.as_ref() {
4155            let offset = state.bytes_written[volume_index];
4156            sink.write_volume(volume_index, root_auth_footer)?;
4157            state.bytes_written[volume_index] = checked_u64_add(
4158                state.bytes_written[volume_index],
4159                root_auth_footer.len() as u64,
4160                "RootAuthFooterV1",
4161            )?;
4162            Some(offset)
4163        } else {
4164            None
4165        };
4166
4167        let trailer_offset = state.bytes_written[volume_index];
4168        let trailer = build_volume_trailer(VolumeTrailerBuildInput {
4169            subkeys,
4170            aead_algo: plan.options.aead_algo,
4171            volume_format_rev: plan.volume_format_rev,
4172            archive_uuid: plan.archive_uuid,
4173            session_id: plan.session_id,
4174            volume_index: volume_index_u32,
4175            block_count: state.record_counts[volume_index],
4176            bytes_written: trailer_offset,
4177            manifest_footer_offset,
4178            closed_at_ns: plan.options.closed_at_ns,
4179            root_auth_footer: root_auth_footer_offset.zip(root_auth_footer_length),
4180        })?;
4181        sink.write_volume(volume_index, &trailer)?;
4182        state.bytes_written[volume_index] = checked_u64_add(
4183            state.bytes_written[volume_index],
4184            VOLUME_TRAILER_LEN as u64,
4185            "VolumeTrailer",
4186        )?;
4187
4188        let cmra_offset = state.bytes_written[volume_index];
4189        let cmra = build_v41_cmra(CmraBuildInput {
4190            volume_format_rev: plan.volume_format_rev,
4191            volume_header_bytes: &state.volume_headers[volume_index],
4192            crypto_header: &plan.crypto_header,
4193            block_count: state.record_counts[volume_index],
4194            block_records_offset: plan.block_records_offset,
4195            manifest_footer_offset,
4196            manifest_footer: &manifest_footer,
4197            root_auth_footer_offset,
4198            root_auth_footer: root_auth_footer.as_deref(),
4199            key_wrap_table: plan.key_wrap_table.as_deref(),
4200            trailer_offset,
4201            trailer: &trailer,
4202            cmra_offset,
4203            options: plan.options,
4204            archive_uuid: plan.archive_uuid,
4205            session_id: plan.session_id,
4206            volume_index: volume_index_u32,
4207        })?;
4208        sink.write_volume(volume_index, &cmra.bytes)?;
4209        state.bytes_written[volume_index] = checked_u64_add(
4210            state.bytes_written[volume_index],
4211            cmra.bytes.len() as u64,
4212            "CMRA",
4213        )?;
4214        let locator_base = CriticalRecoveryLocator {
4215            volume_format_rev: plan.volume_format_rev,
4216            cmra_offset,
4217            cmra_length: u32_len(cmra.bytes.len(), "CMRA")?,
4218            volume_trailer_offset: trailer_offset,
4219            body_bytes_before_cmra: cmra_offset,
4220            archive_uuid_hint: plan.archive_uuid,
4221            session_id_hint: plan.session_id,
4222            volume_index_hint: volume_index_u32,
4223            locator_sequence: 1,
4224            cmra_shard_size: cmra.shard_size,
4225            cmra_data_shard_count: cmra.data_shard_count,
4226            cmra_parity_shard_count: cmra.parity_shard_count,
4227            cmra_image_length: cmra.image_length,
4228            cmra_image_sha256: cmra.image_sha256,
4229            locator_crc32c: 0,
4230        };
4231        let mirror = locator_base.to_bytes();
4232        sink.write_volume(volume_index, &mirror)?;
4233        let final_locator = CriticalRecoveryLocator {
4234            locator_sequence: 0,
4235            ..locator_base
4236        }
4237        .to_bytes();
4238        sink.write_volume(volume_index, &final_locator)?;
4239        state.bytes_written[volume_index] = checked_u64_add(
4240            state.bytes_written[volume_index],
4241            (CRITICAL_RECOVERY_LOCATOR_LEN * 2) as u64,
4242            "critical recovery locators",
4243        )?;
4244
4245        if volume_index == 0 {
4246            debug_assert_eq!(volume_zero_manifest, manifest_footer);
4247        }
4248    }
4249
4250    let bootstrap_sidecar_bytes = if plan.options.stripe_width == 1 {
4251        let sidecar = build_bootstrap_sidecar(
4252            subkeys,
4253            plan.options.aead_algo,
4254            plan.volume_format_rev,
4255            plan.archive_uuid,
4256            plan.session_id,
4257            &volume_zero_manifest,
4258            &index_root_object.records,
4259            dictionary_records.as_deref(),
4260        )?;
4261        let sidecar_len = sidecar.len() as u64;
4262        sink.write_bootstrap_sidecar(&sidecar)?;
4263        sidecar_len
4264    } else {
4265        0
4266    };
4267
4268    Ok(WrittenArchiveSummary {
4269        volume_count,
4270        archive_bytes: state.bytes_written.iter().sum(),
4271        bootstrap_sidecar_bytes,
4272        archive_uuid: plan.archive_uuid,
4273        session_id: plan.session_id,
4274        timings: WriterTimings {
4275            emit_metadata: emit_metadata_started.elapsed(),
4276            ..WriterTimings::default()
4277        },
4278    })
4279}
4280
4281#[allow(clippy::too_many_arguments)]
4282fn emit_encrypted_object<O: ArchiveWriteSink>(
4283    payload: &[u8],
4284    key: &[u8; 32],
4285    nonce_seed: &[u8; 32],
4286    domain: &[u8],
4287    counter: u64,
4288    data_kind: BlockKind,
4289    parity_kind: BlockKind,
4290    data_shard_max: u16,
4291    class_parity_shard_max: u16,
4292    next_block_index: &mut u64,
4293    options: WriterOptions,
4294    archive_uuid: &[u8; 16],
4295    session_id: &[u8; 16],
4296    expected_extent: ObjectExtent,
4297    metadata_kind: Option<MetadataObjectKind>,
4298    volume_format_rev: u16,
4299    sink: &mut O,
4300    bytes_written: &mut [u64],
4301    record_counts: &mut [u64],
4302    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
4303) -> Result<EncryptedObject, ArchiveWriteError> {
4304    let object = encrypt_object(
4305        payload,
4306        ObjectEncryptionContext {
4307            key,
4308            nonce_seed,
4309            domain,
4310            counter,
4311            data_kind,
4312            parity_kind,
4313            data_shard_max,
4314            class_parity_shard_max,
4315            archive_uuid,
4316            session_id,
4317        },
4318        next_block_index,
4319        options,
4320    )
4321    .map_err(|error| match metadata_kind {
4322        Some(kind) => map_metadata_encrypt_error(error, kind),
4323        None => error,
4324    })?;
4325    validate_planned_extent(&object, expected_extent)?;
4326    for record in &object.records {
4327        emit_block_record(
4328            sink,
4329            options,
4330            bytes_written,
4331            record_counts,
4332            volume_format_rev,
4333            data_leaf_hashes,
4334            record,
4335        )?;
4336    }
4337    Ok(object)
4338}
4339
4340fn emit_block_record<O: ArchiveWriteSink>(
4341    sink: &mut O,
4342    options: WriterOptions,
4343    bytes_written: &mut [u64],
4344    record_counts: &mut [u64],
4345    volume_format_rev: u16,
4346    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
4347    record: &BlockRecord,
4348) -> Result<(), ArchiveWriteError> {
4349    let volume_index = (record.block_index % options.stripe_width as u64) as usize;
4350    let record_bytes = record.to_bytes();
4351    sink.write_volume(volume_index, &record_bytes)?;
4352    bytes_written[volume_index] = checked_u64_add(
4353        bytes_written[volume_index],
4354        record_bytes.len() as u64,
4355        "BlockRecord",
4356    )?;
4357    record_counts[volume_index] =
4358        checked_u64_add(record_counts[volume_index], 1, "BlockRecord count")?;
4359    if let Some(data_leaf_hashes) = data_leaf_hashes.as_mut() {
4360        if record.kind.is_data() {
4361            data_leaf_hashes.push((
4362                record.block_index,
4363                data_block_merkle_leaf_hash_for_revision(
4364                    FORMAT_VERSION,
4365                    volume_format_rev,
4366                    record.block_index,
4367                    record.kind,
4368                    record.flags,
4369                    &record.payload,
4370                )?,
4371            ));
4372        }
4373    }
4374    Ok(())
4375}
4376
4377fn emit_serialized_block_record<O: ArchiveWriteSink>(
4378    sink: &mut O,
4379    options: WriterOptions,
4380    bytes_written: &mut [u64],
4381    record_counts: &mut [u64],
4382    record: &SerializedBlockRecord,
4383) -> Result<(), ArchiveWriteError> {
4384    let volume_index = (record.block_index % options.stripe_width as u64) as usize;
4385    sink.write_volume(volume_index, &record.bytes)?;
4386    bytes_written[volume_index] = checked_u64_add(
4387        bytes_written[volume_index],
4388        record.bytes.len() as u64,
4389        "BlockRecord",
4390    )?;
4391    record_counts[volume_index] =
4392        checked_u64_add(record_counts[volume_index], 1, "BlockRecord count")?;
4393    Ok(())
4394}
4395
4396#[allow(clippy::too_many_arguments)]
4397fn emit_payload_stream<S, O>(
4398    files: &[S],
4399    dictionary: Option<&[u8]>,
4400    subkeys: &Subkeys,
4401    plan: &WriterPlan,
4402    next_block_index: &mut u64,
4403    sink: &mut O,
4404    bytes_written: &mut [u64],
4405    record_counts: &mut [u64],
4406    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
4407) -> Result<(), ArchiveWriteError>
4408where
4409    S: RegularFileSource,
4410    O: ArchiveWriteSink,
4411{
4412    let mut envelope = PayloadEnvelopeBuilder {
4413        envelope_index: 0,
4414        plaintext: Vec::new(),
4415    };
4416    let mut next_frame_index = 0u64;
4417
4418    for (member_index, file) in files.iter().enumerate() {
4419        let member = plan
4420            .tar_members
4421            .get(member_index)
4422            .ok_or(FormatError::WriterInvariant(
4423                "planned tar member is missing",
4424            ))?;
4425        let current_path =
4426            normalize_lookup_file_path(file.archive_path(), plan.options.max_path_length)?;
4427        if current_path != member.path
4428            || file.entry_kind() != member.entry_kind
4429            || file.link_target() != member.link_target.as_deref()
4430            || file.file_data_size() != member.file_data_size
4431            || file.sparse_extents() != member.sparse_extents.as_deref()
4432            || file.mode() != member.mode
4433            || file.mtime() != member.mtime
4434            || file.portable_metadata() != member.portable_metadata
4435        {
4436            return Err(FormatError::WriterInvariant(
4437                "file source changed between planning and emission",
4438            )
4439            .into());
4440        }
4441        let layout = build_primary_member_layout(
4442            &member.path,
4443            member.entry_kind,
4444            member.link_target.as_deref(),
4445            member.file_data_size,
4446            member.sparse_extents.as_deref(),
4447            member.mode,
4448            member.mtime,
4449            &member.portable_metadata,
4450        )?;
4451        let source_payload_size = member
4452            .sparse_extents
4453            .as_deref()
4454            .map(|extents| sparse_extent_bytes(extents, member.file_data_size))
4455            .transpose()?
4456            .unwrap_or(member.file_data_size);
4457        let actual_member_group_size = primary_member_layout_size(&layout, source_payload_size)?;
4458        if actual_member_group_size != member.tar_member_group_size {
4459            return Err(FormatError::WriterInvariant(
4460                "streamed auxiliary layout changed between planning and emission",
4461            )
4462            .into());
4463        }
4464        let mut reader = StreamingMemberReader::from_source(
4465            file,
4466            &member.portable_metadata,
4467            layout,
4468            source_payload_size,
4469        )?;
4470        let mut member_offset = 0u64;
4471        while member_offset < member.tar_member_group_size {
4472            let remaining = member.tar_member_group_size - member_offset;
4473            let max_chunk = remaining.min(plan.options.chunk_size as u64);
4474            let mut chunk = vec![0u8; to_usize_writer(max_chunk, "payload chunk")?];
4475            reader
4476                .read_exact(&mut chunk)
4477                .map_err(ArchiveWriteError::Io)?;
4478            let mut chunk_len = chunk.len();
4479            let frame = loop {
4480                let candidate = &chunk[..chunk_len];
4481                let frame = if let Some(dictionary) = dictionary {
4482                    compress_zstd_frame_with_dictionary_and_jobs(
4483                        candidate,
4484                        plan.options.zstd_level,
4485                        dictionary,
4486                        plan.options.jobs,
4487                    )?
4488                } else {
4489                    compress_zstd_frame_with_jobs(
4490                        candidate,
4491                        plan.options.zstd_level,
4492                        plan.options.jobs,
4493                    )?
4494                };
4495                if payload_object_can_fit(frame.len(), plan.options)? {
4496                    break frame;
4497                }
4498                if chunk_len == 1 {
4499                    return Err(FormatError::WriterUnsupported(
4500                        "single-byte payload frame exceeds envelope object limits",
4501                    )
4502                    .into());
4503                }
4504                chunk_len = (chunk_len / 2).max(1);
4505            };
4506            if chunk_len < chunk.len() {
4507                reader.push_back(chunk[chunk_len..].to_vec());
4508            }
4509            append_payload_frame_to_emit(
4510                &mut envelope,
4511                &frame,
4512                chunk_len,
4513                member_index,
4514                member.tar_member_group_start,
4515                member_offset,
4516                member.tar_member_group_size,
4517                &mut next_frame_index,
4518                subkeys,
4519                plan,
4520                next_block_index,
4521                sink,
4522                bytes_written,
4523                record_counts,
4524                data_leaf_hashes,
4525            )?;
4526            member_offset = checked_u64_add(member_offset, chunk_len as u64, "payload chunk")?;
4527        }
4528    }
4529
4530    if !envelope.plaintext.is_empty() {
4531        flush_payload_envelope_emit(
4532            &mut envelope,
4533            subkeys,
4534            plan,
4535            next_block_index,
4536            sink,
4537            bytes_written,
4538            record_counts,
4539            data_leaf_hashes,
4540        )?;
4541    }
4542    if next_frame_index != plan.frames.len() as u64
4543        || envelope.envelope_index != plan.payload_objects.len() as u64
4544    {
4545        return Err(FormatError::WriterInvariant("streaming payload plan mismatch").into());
4546    }
4547    Ok(())
4548}
4549
4550#[allow(clippy::too_many_arguments)]
4551fn append_payload_frame_to_emit<O: ArchiveWriteSink>(
4552    envelope: &mut PayloadEnvelopeBuilder,
4553    frame: &[u8],
4554    decompressed_size: usize,
4555    member_index: usize,
4556    member_start: u64,
4557    member_offset: u64,
4558    member_group_size: u64,
4559    next_frame_index: &mut u64,
4560    subkeys: &Subkeys,
4561    plan: &WriterPlan,
4562    next_block_index: &mut u64,
4563    sink: &mut O,
4564    bytes_written: &mut [u64],
4565    record_counts: &mut [u64],
4566    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
4567) -> Result<(), ArchiveWriteError> {
4568    if payload_envelope_needs_flush(envelope, frame.len(), plan.options)? {
4569        flush_payload_envelope_emit(
4570            envelope,
4571            subkeys,
4572            plan,
4573            next_block_index,
4574            sink,
4575            bytes_written,
4576            record_counts,
4577            data_leaf_hashes,
4578        )?;
4579    }
4580    if envelope.plaintext.is_empty() && !payload_object_can_fit(frame.len(), plan.options)? {
4581        return Err(
4582            FormatError::WriterUnsupported("payload frame exceeds envelope object limits").into(),
4583        );
4584    }
4585    let offset = u32_len(envelope.plaintext.len(), "FrameEntry.offset_in_envelope")?;
4586    let actual = payload_frame_metadata(PayloadFrameMetadataInput {
4587        frame_index: *next_frame_index,
4588        envelope_index: envelope.envelope_index,
4589        member_index,
4590        offset_in_envelope: offset,
4591        compressed_size: frame.len(),
4592        decompressed_size,
4593        member_start,
4594        member_offset,
4595        member_group_size,
4596    })?;
4597    let expected =
4598        plan.frames
4599            .get(*next_frame_index as usize)
4600            .ok_or(FormatError::WriterInvariant(
4601                "planned payload frame is missing",
4602            ))?;
4603    if expected.envelope_index != actual.envelope_index
4604        || expected.member_index != actual.member_index
4605        || expected.offset_in_envelope != actual.offset_in_envelope
4606        || expected.compressed_size != actual.compressed_size
4607        || expected.decompressed_size != actual.decompressed_size
4608        || expected.flags != actual.flags
4609        || expected.tar_stream_offset != actual.tar_stream_offset
4610    {
4611        return Err(
4612            FormatError::WriterInvariant("emitted payload frame does not match plan").into(),
4613        );
4614    }
4615    envelope.plaintext.extend_from_slice(frame);
4616    *next_frame_index = checked_u64_add(*next_frame_index, 1, "PayloadFrame.frame_index")?;
4617    Ok(())
4618}
4619
4620#[allow(clippy::too_many_arguments)]
4621fn flush_payload_envelope_emit<O: ArchiveWriteSink>(
4622    envelope: &mut PayloadEnvelopeBuilder,
4623    subkeys: &Subkeys,
4624    plan: &WriterPlan,
4625    next_block_index: &mut u64,
4626    sink: &mut O,
4627    bytes_written: &mut [u64],
4628    record_counts: &mut [u64],
4629    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
4630) -> Result<(), ArchiveWriteError> {
4631    let expected = plan
4632        .payload_objects
4633        .get(envelope.envelope_index as usize)
4634        .ok_or(FormatError::WriterInvariant(
4635            "planned payload envelope is missing",
4636        ))?;
4637    if expected.envelope_index != envelope.envelope_index
4638        || expected.plaintext_size
4639            != u32_len(envelope.plaintext.len(), "EnvelopeEntry.plaintext_size")?
4640    {
4641        return Err(
4642            FormatError::WriterInvariant("emitted payload envelope does not match plan").into(),
4643        );
4644    }
4645    emit_encrypted_object(
4646        &envelope.plaintext,
4647        &subkeys.enc_key,
4648        &subkeys.nonce_seed,
4649        b"envelope",
4650        envelope.envelope_index,
4651        BlockKind::PayloadData,
4652        BlockKind::PayloadParity,
4653        plan.options.fec_data_shards,
4654        plan.options.fec_parity_shards,
4655        next_block_index,
4656        plan.options,
4657        &plan.archive_uuid,
4658        &plan.session_id,
4659        expected.object,
4660        None,
4661        plan.volume_format_rev,
4662        sink,
4663        bytes_written,
4664        record_counts,
4665        data_leaf_hashes,
4666    )?;
4667    envelope.envelope_index = checked_u64_add(envelope.envelope_index, 1, "EnvelopeEntry")?;
4668    envelope.plaintext.clear();
4669    Ok(())
4670}
4671
4672pub fn write_empty_archive(master_key: &MasterKey) -> Result<WrittenArchive, FormatError> {
4673    write_archive(&[], master_key, WriterOptions::default())
4674}
4675
4676fn plan_writer_options(mut options: WriterOptions) -> Result<WriterOptions, FormatError> {
4677    if options.jobs == 0 {
4678        return Err(FormatError::WriterUnsupported("jobs must be at least 1"));
4679    }
4680    if options.block_size < MIN_BLOCK_SIZE || options.block_size % 2 != 0 {
4681        return Err(FormatError::WriterUnsupported(
4682            "writer requires an even block size of at least 4096",
4683        ));
4684    }
4685    if options.stripe_width == 0 {
4686        return Err(FormatError::WriterUnsupported(
4687            "stripe_width must be non-zero",
4688        ));
4689    }
4690    if options.volume_loss_tolerance as u32 >= options.stripe_width {
4691        return Err(FormatError::WriterUnsupported(
4692            "volume_loss_tolerance must be less than stripe_width",
4693        ));
4694    }
4695    if options.stripe_width == 1 && options.volume_loss_tolerance != 0 {
4696        return Err(FormatError::WriterUnsupported(
4697            "single-volume archives cannot tolerate volume loss",
4698        ));
4699    }
4700    if matches!(options.target_volume_size, Some(0)) {
4701        return Err(FormatError::WriterUnsupported(
4702            "target_volume_size must be non-zero",
4703        ));
4704    }
4705    if options.bit_rot_buffer_pct > 100 {
4706        return Err(FormatError::WriterUnsupported(
4707            "bit_rot_buffer_pct must be at most 100",
4708        ));
4709    }
4710    if options.chunk_size == 0 || options.chunk_size > options.envelope_target_size {
4711        return Err(FormatError::WriterUnsupported(
4712            "chunk_size must be non-zero and no larger than envelope_target_size",
4713        ));
4714    }
4715    if options.fec_data_shards == 0
4716        || options.index_fec_data_shards == 0
4717        || options.index_root_fec_data_shards == 0
4718    {
4719        return Err(FormatError::WriterUnsupported(
4720            "FEC data shard class maxima must be non-zero",
4721        ));
4722    }
4723    options.index_root_fec_data_shards = options
4724        .index_root_fec_data_shards
4725        .max(MIN_INDEX_ROOT_FEC_DATA_SHARDS);
4726    options.fec_parity_shards =
4727        compute_parity_u16(options.fec_data_shards as u64, options, "fec_parity_shards")?;
4728    options.index_fec_parity_shards = compute_parity_u16(
4729        options.index_fec_data_shards as u64,
4730        options,
4731        "index_fec_parity_shards",
4732    )?;
4733    options.index_root_fec_parity_shards = compute_parity_u16(
4734        options.index_root_fec_data_shards as u64,
4735        options,
4736        "index_root_fec_parity_shards",
4737    )?;
4738    validate_writer_options_match_reader_caps(options)?;
4739    Ok(options)
4740}
4741
4742fn validate_writer_options_match_reader_caps(options: WriterOptions) -> Result<(), FormatError> {
4743    CryptoHeaderFixed {
4744        length: CRYPTO_HEADER_FIXED_LEN as u32,
4745        compression_algo: CompressionAlgo::ZstdFramed,
4746        aead_algo: options.aead_algo,
4747        fec_algo: FecAlgo::ReedSolomonGF16,
4748        kdf_algo: if options.aead_algo.is_encrypted() {
4749            KdfAlgo::Raw
4750        } else {
4751            KdfAlgo::None
4752        },
4753        chunk_size: options.chunk_size,
4754        envelope_target_size: options.envelope_target_size,
4755        block_size: options.block_size,
4756        fec_data_shards: options.fec_data_shards,
4757        fec_parity_shards: options.fec_parity_shards,
4758        index_fec_data_shards: options.index_fec_data_shards,
4759        index_fec_parity_shards: options.index_fec_parity_shards,
4760        index_root_fec_data_shards: options.index_root_fec_data_shards,
4761        index_root_fec_parity_shards: options.index_root_fec_parity_shards,
4762        stripe_width: options.stripe_width,
4763        volume_loss_tolerance: options.volume_loss_tolerance,
4764        bit_rot_buffer_pct: options.bit_rot_buffer_pct,
4765        has_dictionary: 0,
4766        max_path_length: options.max_path_length,
4767        expected_volume_size: options.target_volume_size.unwrap_or(0),
4768    }
4769    .validate_supported_profile()
4770}
4771
4772fn build_crypto_header(
4773    options: WriterOptions,
4774    volume_format_rev: u16,
4775    has_dictionary: bool,
4776    subkeys: &Subkeys,
4777    archive_uuid: &[u8; 16],
4778    session_id: &[u8; 16],
4779    kdf_params: &KdfParams,
4780) -> Result<Vec<u8>, FormatError> {
4781    let kdf_payload = serialize_kdf_params(kdf_params)?;
4782    let length = CRYPTO_HEADER_FIXED_LEN
4783        .checked_add(kdf_payload.len())
4784        .and_then(|value| value.checked_add(CRYPTO_EXTENSION_HEADER_LEN))
4785        .and_then(|value| value.checked_add(CRYPTO_HEADER_HMAC_LEN))
4786        .ok_or(FormatError::WriterUnsupported(
4787            "CryptoHeader length overflow",
4788        ))?;
4789    let kdf_algo = match kdf_params {
4790        KdfParams::None => KdfAlgo::None,
4791        KdfParams::Raw => KdfAlgo::Raw,
4792        KdfParams::Argon2id { .. } => KdfAlgo::Argon2id,
4793        KdfParams::RecipientWrap { .. } => KdfAlgo::RecipientWrap,
4794    };
4795    match (options.aead_algo, kdf_algo) {
4796        (AeadAlgo::None, KdfAlgo::None) => {}
4797        (aead_algo, KdfAlgo::Raw | KdfAlgo::Argon2id | KdfAlgo::RecipientWrap)
4798            if aead_algo.is_encrypted() => {}
4799        _ => {
4800            return Err(FormatError::InvalidProtectionMode {
4801                aead_algo: options.aead_algo,
4802                kdf_algo,
4803            });
4804        }
4805    }
4806    let fixed = CryptoHeaderFixed {
4807        length: length as u32,
4808        compression_algo: CompressionAlgo::ZstdFramed,
4809        aead_algo: options.aead_algo,
4810        fec_algo: FecAlgo::ReedSolomonGF16,
4811        kdf_algo,
4812        chunk_size: options.chunk_size,
4813        envelope_target_size: options.envelope_target_size,
4814        block_size: options.block_size,
4815        fec_data_shards: options.fec_data_shards,
4816        fec_parity_shards: options.fec_parity_shards,
4817        index_fec_data_shards: options.index_fec_data_shards,
4818        index_fec_parity_shards: options.index_fec_parity_shards,
4819        index_root_fec_data_shards: options.index_root_fec_data_shards,
4820        index_root_fec_parity_shards: options.index_root_fec_parity_shards,
4821        stripe_width: options.stripe_width,
4822        volume_loss_tolerance: options.volume_loss_tolerance,
4823        bit_rot_buffer_pct: options.bit_rot_buffer_pct,
4824        has_dictionary: if has_dictionary { 1 } else { 0 },
4825        max_path_length: options.max_path_length,
4826        expected_volume_size: options.target_volume_size.unwrap_or(0),
4827    };
4828
4829    let mut bytes = fixed.to_bytes().to_vec();
4830    bytes.extend_from_slice(&kdf_payload);
4831    bytes.extend_from_slice(&0u16.to_le_bytes());
4832    bytes.extend_from_slice(&0u32.to_le_bytes());
4833    let hmac = compute_integrity_tag(
4834        HmacDomain::CryptoHeader,
4835        options.aead_algo,
4836        volume_format_rev,
4837        Some(&subkeys.mac_key),
4838        archive_uuid,
4839        session_id,
4840        &bytes,
4841    )?;
4842    bytes.extend_from_slice(&hmac);
4843    Ok(bytes)
4844}
4845
4846fn serialize_kdf_params(params: &KdfParams) -> Result<Vec<u8>, FormatError> {
4847    let mut bytes = Vec::new();
4848    match params {
4849        KdfParams::None => {
4850            bytes.extend_from_slice(&(KdfAlgo::None as u16).to_le_bytes());
4851        }
4852        KdfParams::Raw => {
4853            bytes.extend_from_slice(&(KdfAlgo::Raw as u16).to_le_bytes());
4854        }
4855        KdfParams::Argon2id {
4856            t_cost,
4857            m_cost_kib,
4858            parallelism,
4859            salt,
4860        } => {
4861            if *t_cost == 0 {
4862                return Err(FormatError::InvalidKdfParams("t_cost must be non-zero"));
4863            }
4864            if *parallelism == 0 {
4865                return Err(FormatError::InvalidKdfParams(
4866                    "parallelism must be non-zero",
4867                ));
4868            }
4869            let min_memory = parallelism
4870                .checked_mul(8)
4871                .ok_or(FormatError::InvalidKdfParams(
4872                    "m_cost_kib requirement overflow",
4873                ))?;
4874            if *m_cost_kib < min_memory {
4875                return Err(FormatError::InvalidKdfParams(
4876                    "m_cost_kib must be at least 8 * parallelism",
4877                ));
4878            }
4879            if !(8..=64).contains(&salt.len()) {
4880                return Err(FormatError::InvalidKdfParams(
4881                    "argon2id salt length must be 8..64",
4882                ));
4883            }
4884            let salt_len = u16::try_from(salt.len())
4885                .map_err(|_| FormatError::InvalidKdfParams("argon2id salt too long"))?;
4886            bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
4887            bytes.extend_from_slice(&t_cost.to_le_bytes());
4888            bytes.extend_from_slice(&m_cost_kib.to_le_bytes());
4889            bytes.extend_from_slice(&parallelism.to_le_bytes());
4890            bytes.extend_from_slice(&salt_len.to_le_bytes());
4891            bytes.extend_from_slice(salt);
4892        }
4893        KdfParams::RecipientWrap {
4894            key_wrap_table_length,
4895            key_wrap_table_record_count,
4896            key_wrap_table_version,
4897            key_wrap_table_digest,
4898        } => {
4899            bytes.extend_from_slice(&(KdfAlgo::RecipientWrap as u16).to_le_bytes());
4900            bytes.extend_from_slice(&key_wrap_table_length.to_le_bytes());
4901            bytes.extend_from_slice(&key_wrap_table_record_count.to_le_bytes());
4902            bytes.extend_from_slice(&key_wrap_table_version.to_le_bytes());
4903            bytes.extend_from_slice(&0u16.to_le_bytes());
4904            bytes.extend_from_slice(key_wrap_table_digest);
4905        }
4906    }
4907    Ok(bytes)
4908}
4909
4910#[cfg(test)]
4911fn build_tar_stream<S: RegularFileSource>(
4912    files: &[S],
4913    max_path_length: u32,
4914) -> Result<(Vec<u8>, Vec<TarMember>), FormatError> {
4915    let mut stream = Vec::new();
4916    let mut members = Vec::with_capacity(files.len());
4917    for file in files {
4918        let path = normalize_lookup_file_path(file.archive_path(), max_path_length)?;
4919        let start = stream.len() as u64;
4920        let sparse_extents = file.sparse_extents().map(<[SparseExtent]>::to_vec);
4921        let source_payload_size = sparse_extents
4922            .as_deref()
4923            .map(|extents| sparse_extent_bytes(extents, file.file_data_size()))
4924            .transpose()?
4925            .unwrap_or(file.file_data_size());
4926        let mut member_group = build_primary_member_prefix(
4927            &path,
4928            file.entry_kind(),
4929            file.link_target(),
4930            file.file_data_size(),
4931            sparse_extents.as_deref(),
4932            file.mode(),
4933            file.mtime(),
4934            &file.portable_metadata(),
4935        )?;
4936        let mut reader = file
4937            .open()
4938            .map_err(|_| FormatError::WriterInvariant("test source failed to open"))?;
4939        let mut payload = Vec::new();
4940        reader
4941            .read_to_end(&mut payload)
4942            .map_err(|_| FormatError::WriterInvariant("test source failed to read"))?;
4943        if payload.len() as u64 != source_payload_size {
4944            return Err(FormatError::WriterInvariant(
4945                "test source payload size mismatch",
4946            ));
4947        }
4948        member_group.extend_from_slice(&payload);
4949        member_group.resize(
4950            member_group.len() + padding_to_512(source_payload_size as usize),
4951            0,
4952        );
4953        stream.extend_from_slice(&member_group);
4954        members.push(TarMember {
4955            path,
4956            entry_kind: file.entry_kind(),
4957            link_target: file.link_target().map(<[u8]>::to_vec),
4958            tar_member_group_start: start,
4959            tar_member_group_size: member_group.len() as u64,
4960            file_data_size: file.file_data_size(),
4961            sparse_extents,
4962            mode: file.mode(),
4963            mtime: file.mtime(),
4964            portable_metadata: file.portable_metadata(),
4965        });
4966    }
4967    Ok((stream, members))
4968}
4969
4970#[cfg(test)]
4971fn build_payload_envelopes(
4972    tar_stream: &[u8],
4973    members: &[TarMember],
4974    options: WriterOptions,
4975    dictionary: Option<&[u8]>,
4976) -> Result<(Vec<PayloadEnvelope>, Vec<PayloadFrame>), FormatError> {
4977    let chunk_size = options.chunk_size as usize;
4978    if chunk_size == 0 {
4979        return Err(FormatError::WriterUnsupported(
4980            "chunk_size must be non-zero and no larger than envelope_target_size",
4981        ));
4982    }
4983    let envelope_target_size = options.envelope_target_size as usize;
4984    let mut envelopes = Vec::new();
4985    let mut current = PayloadEnvelope {
4986        envelope_index: 0,
4987        plaintext: Vec::new(),
4988    };
4989    let mut frames = Vec::new();
4990    let mut next_frame_index = 0u64;
4991
4992    for (member_index, member) in members.iter().enumerate() {
4993        let start = member.tar_member_group_start as usize;
4994        let end = checked_usize_add(start, member.tar_member_group_size as usize, "tar member")?;
4995        let member_bytes = tar_stream
4996            .get(start..end)
4997            .ok_or(FormatError::WriterInvariant(
4998                "tar member range is out of bounds",
4999            ))?;
5000        let mut member_offset = 0usize;
5001        while member_offset < member_bytes.len() {
5002            let mut chunk_len = (member_bytes.len() - member_offset).min(chunk_size);
5003            let frame = loop {
5004                let end = checked_usize_add(member_offset, chunk_len, "payload chunk")?;
5005                let chunk = &member_bytes[member_offset..end];
5006                let frame = if let Some(dictionary) = dictionary {
5007                    compress_zstd_frame_with_dictionary_and_jobs(
5008                        chunk,
5009                        options.zstd_level,
5010                        dictionary,
5011                        options.jobs,
5012                    )?
5013                } else {
5014                    compress_zstd_frame_with_jobs(chunk, options.zstd_level, options.jobs)?
5015                };
5016                if payload_object_can_fit(frame.len(), options)? {
5017                    break frame;
5018                }
5019                if chunk_len == 1 {
5020                    return Err(FormatError::WriterUnsupported(
5021                        "single-byte payload frame exceeds envelope object limits",
5022                    ));
5023                }
5024                chunk_len = (chunk_len / 2).max(1);
5025            };
5026            let next_len = checked_usize_add(current.plaintext.len(), frame.len(), "payload")?;
5027            if !current.plaintext.is_empty()
5028                && (next_len > envelope_target_size || !payload_object_can_fit(next_len, options)?)
5029            {
5030                envelopes.push(current);
5031                current = PayloadEnvelope {
5032                    envelope_index: envelopes.len() as u64,
5033                    plaintext: Vec::new(),
5034                };
5035            }
5036
5037            if current.plaintext.is_empty() && !payload_object_can_fit(frame.len(), options)? {
5038                return Err(FormatError::WriterUnsupported(
5039                    "payload frame exceeds envelope object limits",
5040                ));
5041            }
5042            let offset = u32_len(current.plaintext.len(), "FrameEntry.offset_in_envelope")?;
5043            current.plaintext.extend_from_slice(&frame);
5044            let is_first_member_frame = member_offset == 0;
5045            let is_last_member_frame =
5046                checked_usize_add(member_offset, chunk_len, "payload chunk")? == member_bytes.len();
5047            let mut flags = 0u32;
5048            if is_first_member_frame {
5049                flags |= 0x0000_0001;
5050            }
5051            if is_last_member_frame {
5052                flags |= 0x0000_0002;
5053            }
5054            frames.push(PayloadFrame {
5055                frame_index: next_frame_index,
5056                envelope_index: current.envelope_index,
5057                member_index,
5058                offset_in_envelope: offset,
5059                compressed_size: u32_len(frame.len(), "FrameEntry.compressed_size")?,
5060                decompressed_size: u32_len(chunk_len, "FrameEntry.decompressed_size")?,
5061                flags,
5062                tar_stream_offset: checked_u64_add(
5063                    member.tar_member_group_start,
5064                    u64::try_from(member_offset)
5065                        .map_err(|_| FormatError::WriterUnsupported("chunk offset"))?,
5066                    "PayloadFrame.tar_stream_offset",
5067                )?,
5068            });
5069            next_frame_index = checked_u64_add(next_frame_index, 1, "PayloadFrame.frame_index")?;
5070            member_offset = checked_usize_add(member_offset, chunk_len, "payload chunk")?;
5071        }
5072    }
5073    if !current.plaintext.is_empty() {
5074        envelopes.push(current);
5075    }
5076    Ok((envelopes, frames))
5077}
5078
5079fn sorted_file_rows(members: &[TarMember]) -> Vec<FileRow> {
5080    let mut rows = members
5081        .iter()
5082        .enumerate()
5083        .map(|(member_index, member)| FileRow {
5084            path_hash: hash_prefix(&member.path),
5085            path: member.path.clone(),
5086            member_index,
5087            member: member.clone(),
5088        })
5089        .collect::<Vec<_>>();
5090    rows.sort_by(|left, right| {
5091        (
5092            left.path_hash,
5093            left.path.as_slice(),
5094            left.member.tar_member_group_start,
5095        )
5096            .cmp(&(
5097                right.path_hash,
5098                right.path.as_slice(),
5099                right.member.tar_member_group_start,
5100            ))
5101    });
5102    rows
5103}
5104
5105fn partition_file_rows(rows: Vec<FileRow>) -> Result<Vec<Vec<FileRow>>, FormatError> {
5106    let mut shards = Vec::new();
5107    let mut start = 0usize;
5108    while start < rows.len() {
5109        let mut end = (start + DEFAULT_FILES_PER_INDEX_SHARD).min(rows.len());
5110        if end < rows.len() && rows[end - 1].path_hash == rows[end].path_hash {
5111            let boundary_hash = rows[end].path_hash;
5112            let mut run_start_in_shard = end - 1;
5113            while run_start_in_shard > start
5114                && rows[run_start_in_shard - 1].path_hash == boundary_hash
5115            {
5116                run_start_in_shard -= 1;
5117            }
5118            let mut full_run_start = run_start_in_shard;
5119            while full_run_start > 0 && rows[full_run_start - 1].path_hash == boundary_hash {
5120                full_run_start -= 1;
5121            }
5122            let mut full_run_end = end + 1;
5123            while full_run_end < rows.len() && rows[full_run_end].path_hash == boundary_hash {
5124                full_run_end += 1;
5125            }
5126            let full_run_len = full_run_end - full_run_start;
5127            end = if full_run_len <= MAX_HASH_PREFIX_RUN_FILES {
5128                full_run_end
5129            } else {
5130                (run_start_in_shard + MAX_HASH_PREFIX_RUN_FILES).min(full_run_end)
5131            };
5132        }
5133        if end - start > MAX_FILES_PER_INDEX_SHARD {
5134            return Err(FormatError::WriterUnsupported(
5135                "hash-prefix collision run exceeds max_files_per_index_shard",
5136            ));
5137        }
5138        shards.push(rows[start..end].to_vec());
5139        start = end;
5140    }
5141    Ok(shards)
5142}
5143
5144fn build_index_shard_plaintexts(
5145    shard_rows: &[Vec<FileRow>],
5146    frames: &[PayloadFrame],
5147    payloads: &[PayloadObject],
5148    options: WriterOptions,
5149) -> Result<Vec<PlannedIndexShard>, FormatError> {
5150    let mut planned = Vec::new();
5151    for rows in shard_rows {
5152        append_index_shards_for_rows(&mut planned, rows, frames, payloads, options)?;
5153    }
5154    Ok(planned)
5155}
5156
5157fn append_index_shards_for_rows(
5158    planned: &mut Vec<PlannedIndexShard>,
5159    rows: &[FileRow],
5160    frames: &[PayloadFrame],
5161    payloads: &[PayloadObject],
5162    options: WriterOptions,
5163) -> Result<(), FormatError> {
5164    let shard_index =
5165        u64::try_from(planned.len()).map_err(|_| FormatError::WriterUnsupported("shard_index"))?;
5166    let candidate = build_index_shard_plaintext(shard_index, rows, frames, payloads, options)?;
5167    let compressed =
5168        compress_zstd_frame_with_jobs(&candidate.plaintext, options.zstd_level, options.jobs)?;
5169    if index_object_can_fit(compressed.len(), options)? {
5170        planned.push(candidate);
5171        return Ok(());
5172    }
5173    if rows.len() == 1 {
5174        return Err(FormatError::WriterUnsupported(
5175            "single-file IndexShard exceeds index object limits",
5176        ));
5177    }
5178    let split_at = split_sorted_file_rows_for_object_limit(rows);
5179    append_index_shards_for_rows(planned, &rows[..split_at], frames, payloads, options)?;
5180    append_index_shards_for_rows(planned, &rows[split_at..], frames, payloads, options)
5181}
5182
5183fn split_sorted_file_rows_for_object_limit(rows: &[FileRow]) -> usize {
5184    let midpoint = rows.len() / 2;
5185    if rows[midpoint - 1].path_hash != rows[midpoint].path_hash {
5186        return midpoint;
5187    }
5188
5189    let boundary_hash = rows[midpoint].path_hash;
5190    let mut left = midpoint;
5191    while left > 0 && rows[left - 1].path_hash == boundary_hash {
5192        left -= 1;
5193    }
5194    let mut right = midpoint;
5195    while right < rows.len() && rows[right].path_hash == boundary_hash {
5196        right += 1;
5197    }
5198
5199    match (left > 0, right < rows.len()) {
5200        (true, true) if midpoint - left <= right - midpoint => left,
5201        (true, true) => right,
5202        (true, false) => left,
5203        (false, true) => right,
5204        (false, false) => midpoint,
5205    }
5206}
5207
5208fn build_index_shard_plaintext(
5209    shard_index: u64,
5210    file_rows: &[FileRow],
5211    frames: &[PayloadFrame],
5212    payloads: &[PayloadObject],
5213    options: WriterOptions,
5214) -> Result<PlannedIndexShard, FormatError> {
5215    let mut string_pool = Vec::new();
5216    let mut file_entries = Vec::with_capacity(file_rows.len());
5217    let mut required_frame_indexes = BTreeSet::new();
5218    for row in file_rows {
5219        let path_offset = u32_len(string_pool.len(), "FileEntry.path_offset")?;
5220        string_pool.extend_from_slice(&row.path);
5221        let (first_frame_index, frame_count) = member_frame_range(row.member_index, frames)?;
5222        for offset in 0..frame_count as u64 {
5223            required_frame_indexes.insert(checked_u64_add(
5224                first_frame_index,
5225                offset,
5226                "FileEntry.frame_count",
5227            )?);
5228        }
5229        file_entries.push(FileEntry {
5230            path_hash: row.path_hash,
5231            path_offset,
5232            path_length: u32_len(row.path.len(), "FileEntry.path_length")?,
5233            first_frame_index,
5234            frame_count,
5235            offset_in_first_frame_plaintext: 0,
5236            tar_member_group_size: row.member.tar_member_group_size,
5237            file_data_size: row.member.file_data_size,
5238            flags: v45_portable_file_entry_flags(
5239                row.member.mode,
5240                row.member.sparse_extents.is_some(),
5241                &row.member.portable_metadata,
5242            ),
5243        });
5244    }
5245
5246    let frame_entries = frames
5247        .iter()
5248        .filter(|frame| required_frame_indexes.contains(&frame.frame_index))
5249        .map(|frame| FrameEntry {
5250            frame_index: frame.frame_index,
5251            envelope_index: frame.envelope_index,
5252            offset_in_envelope: frame.offset_in_envelope,
5253            compressed_size: frame.compressed_size,
5254            decompressed_size: frame.decompressed_size,
5255            flags: frame.flags,
5256            tar_stream_offset: frame.tar_stream_offset,
5257        })
5258        .collect::<Vec<_>>();
5259    let required_envelope_indexes = frame_entries
5260        .iter()
5261        .map(|frame| frame.envelope_index)
5262        .collect::<BTreeSet<_>>();
5263    let envelope_entries = payloads
5264        .iter()
5265        .filter(|payload| required_envelope_indexes.contains(&payload.envelope_index))
5266        .map(|payload| {
5267            let (first_frame_index, frame_count) =
5268                envelope_frame_range(payload.envelope_index, frames)?;
5269            Ok(EnvelopeEntry {
5270                envelope_index: payload.envelope_index,
5271                first_block_index: payload.object.first_block_index,
5272                data_block_count: payload.object.data_block_count,
5273                parity_block_count: payload.object.parity_block_count,
5274                encrypted_size: payload.object.encrypted_size,
5275                plaintext_size: payload.plaintext_size,
5276                first_frame_index,
5277                frame_count,
5278            })
5279        })
5280        .collect::<Result<Vec<_>, FormatError>>()?;
5281
5282    let plaintext = serialize_index_shard(
5283        shard_index,
5284        &file_entries,
5285        &frame_entries,
5286        &envelope_entries,
5287        &string_pool,
5288        options,
5289    )?;
5290    let first_path_hash = file_rows
5291        .first()
5292        .ok_or(FormatError::WriterInvariant("empty planned IndexShard"))?
5293        .path_hash;
5294    let last_path_hash = file_rows
5295        .last()
5296        .ok_or(FormatError::WriterInvariant("empty planned IndexShard"))?
5297        .path_hash;
5298    Ok(PlannedIndexShard {
5299        shard_index,
5300        plaintext,
5301        file_count: u32_len(file_rows.len(), "IndexShard.file_count")?,
5302        first_path_hash,
5303        last_path_hash,
5304    })
5305}
5306
5307fn serialize_index_shard(
5308    shard_index: u64,
5309    files: &[FileEntry],
5310    frames: &[FrameEntry],
5311    envelopes: &[EnvelopeEntry],
5312    string_pool: &[u8],
5313    _options: WriterOptions,
5314) -> Result<Vec<u8>, FormatError> {
5315    let mut cursor = INDEX_SHARD_HEADER_LEN;
5316    let file_table_offset = table_offset(files.len(), cursor)?;
5317    cursor = checked_usize_add(cursor, files.len() * FILE_ENTRY_LEN, "IndexShard")?;
5318    let frame_table_offset = table_offset(frames.len(), cursor)?;
5319    cursor = checked_usize_add(cursor, frames.len() * FRAME_ENTRY_LEN, "IndexShard")?;
5320    let envelope_table_offset = table_offset(envelopes.len(), cursor)?;
5321    cursor = checked_usize_add(cursor, envelopes.len() * ENVELOPE_ENTRY_LEN, "IndexShard")?;
5322    let string_pool_offset = table_offset(string_pool.len(), cursor)?;
5323
5324    let header = IndexShardHeader {
5325        version: 1,
5326        shard_index,
5327        file_count: u32_len(files.len(), "IndexShard.file_count")?,
5328        frame_count: u32_len(frames.len(), "IndexShard.frame_count")?,
5329        envelope_count: u32_len(envelopes.len(), "IndexShard.envelope_count")?,
5330        file_table_offset,
5331        frame_table_offset,
5332        envelope_table_offset,
5333        string_pool_offset,
5334        string_pool_size: u32_len(string_pool.len(), "IndexShard.string_pool_size")?,
5335    };
5336
5337    let mut bytes = Vec::with_capacity(cursor + string_pool.len());
5338    bytes.extend_from_slice(&header.to_bytes());
5339    for entry in files {
5340        bytes.extend_from_slice(&entry.to_bytes());
5341    }
5342    for entry in frames {
5343        bytes.extend_from_slice(&entry.to_bytes());
5344    }
5345    for entry in envelopes {
5346        bytes.extend_from_slice(&entry.to_bytes());
5347    }
5348    bytes.extend_from_slice(string_pool);
5349    Ok(bytes)
5350}
5351
5352fn build_directory_hint_plaintexts(
5353    shard_rows: &[Vec<FileRow>],
5354    options: WriterOptions,
5355) -> Result<Vec<PlannedDirectoryHintShard>, FormatError> {
5356    let mut map = BTreeMap::<Vec<u8>, BTreeSet<u32>>::new();
5357    for (shard_row_index, rows) in shard_rows.iter().enumerate() {
5358        let shard_row_index = u32::try_from(shard_row_index)
5359            .map_err(|_| FormatError::WriterUnsupported("directory hint shard row index"))?;
5360        for row in rows {
5361            add_directory_hint_rows(&mut map, shard_row_index, &row.path);
5362        }
5363    }
5364
5365    let rows = map
5366        .into_iter()
5367        .map(|(path, shard_rows)| (hash_prefix(&path), path, shard_rows))
5368        .collect::<Vec<_>>();
5369    let mut rows = rows;
5370    rows.sort_by(|left, right| (left.0, left.1.as_slice()).cmp(&(right.0, right.1.as_slice())));
5371
5372    let mut planned = Vec::new();
5373    for chunk in rows.chunks(DEFAULT_DIRECTORY_HINT_ENTRIES_PER_SHARD) {
5374        append_directory_hint_shards_for_rows(&mut planned, chunk, options)?;
5375    }
5376    Ok(planned)
5377}
5378
5379fn append_directory_hint_shards_for_rows(
5380    planned: &mut Vec<PlannedDirectoryHintShard>,
5381    rows: &[([u8; 8], Vec<u8>, BTreeSet<u32>)],
5382    options: WriterOptions,
5383) -> Result<(), FormatError> {
5384    let hint_shard_index = u64::try_from(planned.len())
5385        .map_err(|_| FormatError::WriterUnsupported("hint_shard_index"))?;
5386    let candidate = build_directory_hint_plaintext(hint_shard_index, rows)?;
5387    let compressed =
5388        compress_zstd_frame_with_jobs(&candidate.plaintext, options.zstd_level, options.jobs)?;
5389    if index_object_can_fit(compressed.len(), options)? {
5390        planned.push(candidate);
5391        return Ok(());
5392    }
5393    if rows.len() == 1 {
5394        return Err(FormatError::WriterUnsupported(
5395            "single DirectoryHintEntry exceeds index object limits",
5396        ));
5397    }
5398    let split_at = rows.len() / 2;
5399    append_directory_hint_shards_for_rows(planned, &rows[..split_at], options)?;
5400    append_directory_hint_shards_for_rows(planned, &rows[split_at..], options)
5401}
5402
5403fn add_directory_hint_rows(
5404    map: &mut BTreeMap<Vec<u8>, BTreeSet<u32>>,
5405    shard_row_index: u32,
5406    path: &[u8],
5407) {
5408    map.entry(Vec::new()).or_default().insert(shard_row_index);
5409    let mut cursor = 0usize;
5410    while let Some(position) = path[cursor..].iter().position(|byte| *byte == b'/') {
5411        let slash = cursor + position;
5412        if slash > 0 {
5413            map.entry(path[..slash].to_vec())
5414                .or_default()
5415                .insert(shard_row_index);
5416        }
5417        cursor = slash + 1;
5418    }
5419}
5420
5421fn build_directory_hint_plaintext(
5422    hint_shard_index: u64,
5423    rows: &[([u8; 8], Vec<u8>, BTreeSet<u32>)],
5424) -> Result<PlannedDirectoryHintShard, FormatError> {
5425    let mut entries = Vec::with_capacity(rows.len());
5426    let mut shard_row_indexes = Vec::new();
5427    let mut string_pool = Vec::new();
5428
5429    for (dir_hash, path, shard_rows) in rows {
5430        let path_offset = if path.is_empty() {
5431            0
5432        } else {
5433            u64::try_from(string_pool.len())
5434                .map_err(|_| FormatError::WriterUnsupported("DirectoryHintEntry.path_offset"))?
5435        };
5436        if !path.is_empty() {
5437            string_pool.extend_from_slice(path);
5438        }
5439        let shard_list_start_index = u32_len(
5440            shard_row_indexes.len(),
5441            "DirectoryHintEntry.shard_list_start_index",
5442        )?;
5443        shard_row_indexes.extend(shard_rows.iter().copied());
5444        entries.push(DirectoryHintEntry {
5445            dir_hash: *dir_hash,
5446            path_offset,
5447            path_length: u32_len(path.len(), "DirectoryHintEntry.path_length")?,
5448            shard_list_start_index,
5449            shard_count: u32_len(shard_rows.len(), "DirectoryHintEntry.shard_count")?,
5450        });
5451    }
5452
5453    let plaintext = serialize_directory_hint_table(
5454        hint_shard_index,
5455        &entries,
5456        &shard_row_indexes,
5457        &string_pool,
5458    )?;
5459    let first_dir_hash = rows
5460        .first()
5461        .ok_or(FormatError::WriterInvariant("empty directory hint shard"))?
5462        .0;
5463    let last_dir_hash = rows
5464        .last()
5465        .ok_or(FormatError::WriterInvariant("empty directory hint shard"))?
5466        .0;
5467    Ok(PlannedDirectoryHintShard {
5468        hint_shard_index,
5469        plaintext,
5470        entry_count: rows.len() as u64,
5471        first_dir_hash,
5472        last_dir_hash,
5473    })
5474}
5475
5476fn serialize_directory_hint_table(
5477    hint_shard_index: u64,
5478    entries: &[DirectoryHintEntry],
5479    shard_row_indexes: &[u32],
5480    string_pool: &[u8],
5481) -> Result<Vec<u8>, FormatError> {
5482    let entry_table_offset = table_offset(entries.len(), DIRECTORY_HINT_TABLE_LEN)?;
5483    let shard_list_cursor = checked_usize_add(
5484        DIRECTORY_HINT_TABLE_LEN,
5485        entries.len() * DIRECTORY_HINT_ENTRY_LEN,
5486        "DirectoryHintTable",
5487    )?;
5488    let shard_list_offset = table_offset(shard_row_indexes.len(), shard_list_cursor)?;
5489    let string_pool_cursor = checked_usize_add(
5490        shard_list_cursor,
5491        shard_row_indexes.len() * 4,
5492        "DirectoryHintTable",
5493    )?;
5494    let string_pool_offset = if string_pool.is_empty() {
5495        0
5496    } else {
5497        u64::try_from(string_pool_cursor)
5498            .map_err(|_| FormatError::WriterUnsupported("DirectoryHintTable.string_pool_offset"))?
5499    };
5500    let header = DirectoryHintTableHeader {
5501        version: 1,
5502        hint_shard_index,
5503        entry_count: entries.len() as u64,
5504        entry_table_offset: entry_table_offset as u64,
5505        shard_list_offset: shard_list_offset as u64,
5506        string_pool_offset,
5507        string_pool_size: string_pool.len() as u64,
5508    };
5509
5510    let mut bytes = Vec::with_capacity(string_pool_cursor + string_pool.len());
5511    bytes.extend_from_slice(&header.to_bytes());
5512    for entry in entries {
5513        bytes.extend_from_slice(&entry.to_bytes());
5514    }
5515    for row in shard_row_indexes {
5516        bytes.extend_from_slice(&row.to_le_bytes());
5517    }
5518    bytes.extend_from_slice(string_pool);
5519    Ok(bytes)
5520}
5521
5522#[derive(Debug, Clone, Copy)]
5523struct IndexRootPlaintextInput<'a> {
5524    shard_entries: &'a [ShardEntry],
5525    frame_count: u64,
5526    envelope_count: u64,
5527    file_count: u64,
5528    payload_block_count: u64,
5529    tar_total_size: u64,
5530    content_sha256: [u8; 32],
5531    directory_hint_entries: &'a [DirectoryHintShardEntry],
5532    dictionary_extent: Option<(ObjectExtent, u32)>,
5533}
5534
5535fn build_index_root_plaintext(input: IndexRootPlaintextInput<'_>) -> Vec<u8> {
5536    let mut header = IndexRootHeader::empty();
5537    header.frame_count = input.frame_count;
5538    header.envelope_count = input.envelope_count;
5539    header.file_count = input.file_count;
5540    header.payload_block_count = input.payload_block_count;
5541    header.tar_total_size = input.tar_total_size;
5542    header.content_sha256 = input.content_sha256;
5543    if let Some((dictionary, decompressed_size)) = input.dictionary_extent {
5544        header.dictionary_first_block = dictionary.first_block_index;
5545        header.dictionary_data_block_count = dictionary.data_block_count;
5546        header.dictionary_parity_block_count = dictionary.parity_block_count;
5547        header.dictionary_encrypted_size = dictionary.encrypted_size;
5548        header.dictionary_decompressed_size = decompressed_size;
5549    }
5550    let root = IndexRoot {
5551        header,
5552        shards: input.shard_entries.to_vec(),
5553        directory_hint_shards: input.directory_hint_entries.to_vec(),
5554    };
5555    root.to_bytes()
5556}
5557
5558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5559struct MetadataClassPlan {
5560    options: WriterOptions,
5561    index_root: PlannedEncryptedObject,
5562    dictionary: Option<PlannedEncryptedObject>,
5563}
5564
5565fn plan_index_root_metadata_class(
5566    mut options: WriterOptions,
5567    compressed_index_root_len: usize,
5568    compressed_dictionary_len: Option<usize>,
5569) -> Result<MetadataClassPlan, FormatError> {
5570    let index_root = plan_metadata_object_without_class(
5571        compressed_index_root_len,
5572        options,
5573        MetadataObjectKind::IndexRoot,
5574    )?;
5575    let dictionary = compressed_dictionary_len
5576        .map(|len| plan_metadata_object_without_class(len, options, MetadataObjectKind::Dictionary))
5577        .transpose()?;
5578    let required_data_shards = u32::from(options.index_root_fec_data_shards)
5579        .max(MIN_INDEX_ROOT_FEC_DATA_SHARDS as u32)
5580        .max(index_root.data_block_count)
5581        .max(dictionary.map(|plan| plan.data_block_count).unwrap_or(0));
5582    let required_data_shards = u16::try_from(required_data_shards)
5583        .map_err(|_| MetadataObjectKind::IndexRoot.too_large_error())?;
5584    options.index_root_fec_data_shards = required_data_shards;
5585    let required_parity_shards = compute_parity_u16(
5586        options.index_root_fec_data_shards as u64,
5587        options,
5588        "index_root_fec_parity_shards",
5589    )?;
5590    options.index_root_fec_parity_shards = options
5591        .index_root_fec_parity_shards
5592        .max(required_parity_shards);
5593    ensure_metadata_object_fits_class(index_root, options, MetadataObjectKind::IndexRoot)?;
5594    if let Some(dictionary) = dictionary {
5595        ensure_metadata_object_fits_class(dictionary, options, MetadataObjectKind::Dictionary)?;
5596    }
5597    Ok(MetadataClassPlan {
5598        options,
5599        index_root,
5600        dictionary,
5601    })
5602}
5603
5604fn plan_metadata_object_without_class(
5605    payload_len: usize,
5606    options: WriterOptions,
5607    kind: MetadataObjectKind,
5608) -> Result<PlannedEncryptedObject, FormatError> {
5609    let plan = plan_encrypted_object_without_class(payload_len, options)
5610        .map_err(|_| kind.too_large_error())?;
5611    if plan.data_block_count > u16::MAX as u32 || plan.parity_block_count > u16::MAX as u32 {
5612        return Err(kind.too_large_error());
5613    }
5614    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)
5615        .map_err(|_| kind.too_large_error())?;
5616    Ok(plan)
5617}
5618
5619fn ensure_metadata_object_fits_class(
5620    plan: PlannedEncryptedObject,
5621    options: WriterOptions,
5622    kind: MetadataObjectKind,
5623) -> Result<(), FormatError> {
5624    if plan.data_block_count > options.index_root_fec_data_shards as u32 {
5625        return Err(kind.too_large_error());
5626    }
5627    if plan.parity_block_count > options.index_root_fec_parity_shards as u32 {
5628        return Err(kind.too_large_error());
5629    }
5630    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)
5631        .map_err(|_| kind.too_large_error())
5632}
5633
5634fn payload_object_can_fit(payload_len: usize, options: WriterOptions) -> Result<bool, FormatError> {
5635    encrypted_object_can_fit(
5636        payload_len,
5637        options.fec_data_shards,
5638        options.fec_parity_shards,
5639        options,
5640    )
5641}
5642
5643fn index_object_can_fit(payload_len: usize, options: WriterOptions) -> Result<bool, FormatError> {
5644    encrypted_object_can_fit(
5645        payload_len,
5646        options.index_fec_data_shards,
5647        options.index_fec_parity_shards,
5648        options,
5649    )
5650}
5651
5652fn encrypted_object_can_fit(
5653    payload_len: usize,
5654    data_shard_max: u16,
5655    parity_shard_max: u16,
5656    options: WriterOptions,
5657) -> Result<bool, FormatError> {
5658    match plan_encrypted_object(payload_len, data_shard_max, parity_shard_max, options) {
5659        Ok(_) => Ok(true),
5660        Err(FormatError::WriterUnsupported("encrypted object exceeds u32 size limit"))
5661        | Err(FormatError::WriterUnsupported(
5662            "encrypted object exceeds its data shard class maximum",
5663        ))
5664        | Err(FormatError::WriterUnsupported(
5665            "encrypted object exceeds its parity shard class maximum",
5666        ))
5667        | Err(FormatError::WriterUnsupported(
5668            "encrypted object exceeds ReedSolomonGF16 shard limit",
5669        )) => Ok(false),
5670        Err(err) => Err(err),
5671    }
5672}
5673
5674fn plan_encrypted_object(
5675    payload_len: usize,
5676    data_shard_max: u16,
5677    parity_shard_max: u16,
5678    options: WriterOptions,
5679) -> Result<PlannedEncryptedObject, FormatError> {
5680    let plan = plan_encrypted_object_without_class(payload_len, options)?;
5681    if plan.data_block_count > data_shard_max as u32 {
5682        return Err(FormatError::WriterUnsupported(
5683            "encrypted object exceeds its data shard class maximum",
5684        ));
5685    }
5686    if plan.parity_block_count > parity_shard_max as u32 {
5687        return Err(FormatError::WriterUnsupported(
5688            "encrypted object exceeds its parity shard class maximum",
5689        ));
5690    }
5691    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)?;
5692    Ok(plan)
5693}
5694
5695fn plan_encrypted_object_without_class(
5696    payload_len: usize,
5697    options: WriterOptions,
5698) -> Result<PlannedEncryptedObject, FormatError> {
5699    let (data_block_count, encrypted_size) = encrypted_object_data_extent(payload_len, options)?;
5700    let parity_block_count = compute_parity(data_block_count as u64, options)?;
5701    Ok(PlannedEncryptedObject {
5702        data_block_count,
5703        parity_block_count,
5704        encrypted_size,
5705    })
5706}
5707
5708fn encrypted_object_data_extent(
5709    payload_len: usize,
5710    options: WriterOptions,
5711) -> Result<(u32, u32), FormatError> {
5712    let block_size = options.block_size as usize;
5713    let tag_len = options.aead_algo.tag_len();
5714    let total_before_padding =
5715        payload_len
5716            .checked_add(tag_len)
5717            .ok_or(FormatError::WriterUnsupported(
5718                "encrypted object size overflow",
5719            ))?;
5720    let remainder = total_before_padding % block_size;
5721    let encrypted_size = if remainder == 0 {
5722        total_before_padding
5723            .checked_add(block_size)
5724            .ok_or(FormatError::WriterUnsupported(
5725                "encrypted object size overflow",
5726            ))?
5727    } else {
5728        total_before_padding
5729            .checked_add(block_size - remainder)
5730            .ok_or(FormatError::WriterUnsupported(
5731                "encrypted object size overflow",
5732            ))?
5733    };
5734    let encrypted_size = u32_len(encrypted_size, "encrypted_size")
5735        .map_err(|_| FormatError::WriterUnsupported("encrypted object exceeds u32 size limit"))?;
5736    Ok((encrypted_size / options.block_size, encrypted_size))
5737}
5738
5739#[derive(Debug, Clone, Copy)]
5740struct ObjectEncryptionContext<'a> {
5741    key: &'a [u8; 32],
5742    nonce_seed: &'a [u8; 32],
5743    domain: &'a [u8],
5744    counter: u64,
5745    data_kind: BlockKind,
5746    parity_kind: BlockKind,
5747    data_shard_max: u16,
5748    class_parity_shard_max: u16,
5749    archive_uuid: &'a [u8; 16],
5750    session_id: &'a [u8; 16],
5751}
5752
5753fn encrypt_object(
5754    payload: &[u8],
5755    context: ObjectEncryptionContext<'_>,
5756    next_block_index: &mut u64,
5757    options: WriterOptions,
5758) -> Result<EncryptedObject, FormatError> {
5759    let block_size = options.block_size as usize;
5760    let encrypted = encrypt_object_payload(payload, context, options)?;
5761    if encrypted.len() % block_size != 0 {
5762        return Err(FormatError::WriterInvariant(
5763            "encrypted object is not block aligned",
5764        ));
5765    }
5766    let encrypted_size = u32_len(encrypted.len(), "encrypted_size")?;
5767    let data_shards = encrypted
5768        .chunks(block_size)
5769        .map(|chunk| chunk.to_vec())
5770        .collect::<Vec<_>>();
5771    let data_block_count = u32_len(data_shards.len(), "data_block_count")?;
5772    if data_block_count == 0 {
5773        return Err(FormatError::WriterInvariant(
5774            "encrypted object has no data blocks",
5775        ));
5776    }
5777    if data_block_count > context.data_shard_max as u32 {
5778        return Err(FormatError::WriterUnsupported(
5779            "encrypted object exceeds its data shard class maximum",
5780        ));
5781    }
5782    let required_parity = compute_object_parity(
5783        data_block_count as u64,
5784        options,
5785        context.class_parity_shard_max as u32,
5786    )?;
5787    if required_parity > context.class_parity_shard_max as u32 {
5788        return Err(FormatError::WriterUnsupported(
5789            "encrypted object exceeds its parity shard class maximum",
5790        ));
5791    }
5792    validate_object_shard_total(data_block_count, required_parity)?;
5793    let parity_count = required_parity as u16;
5794    let parity_shards = if parity_count == 0 {
5795        Vec::new()
5796    } else {
5797        encode_parity_gf16(&data_shards, parity_count as usize)?
5798    };
5799
5800    let first_block_index = *next_block_index;
5801    let mut records = Vec::with_capacity(data_shards.len() + parity_shards.len());
5802    for (index, payload) in data_shards.into_iter().enumerate() {
5803        records.push(BlockRecord {
5804            block_index: checked_u64_add(first_block_index, index as u64, "BlockRecord")?,
5805            kind: context.data_kind,
5806            flags: if index + 1 == data_block_count as usize {
5807                0x01
5808            } else {
5809                0
5810            },
5811            payload,
5812            record_crc32c: 0,
5813        });
5814    }
5815    let parity_first_block = checked_u64_add(first_block_index, data_block_count as u64, "FEC")?;
5816    for (index, payload) in parity_shards.into_iter().enumerate() {
5817        records.push(BlockRecord {
5818            block_index: checked_u64_add(parity_first_block, index as u64, "BlockRecord")?,
5819            kind: context.parity_kind,
5820            flags: 0,
5821            payload,
5822            record_crc32c: 0,
5823        });
5824    }
5825
5826    *next_block_index = checked_u64_add(
5827        first_block_index,
5828        data_block_count as u64 + parity_count as u64,
5829        "next_block_index",
5830    )?;
5831
5832    Ok(EncryptedObject {
5833        first_block_index,
5834        data_block_count,
5835        parity_block_count: parity_count as u32,
5836        encrypted_size,
5837        records,
5838    })
5839}
5840
5841fn serialize_zero_parity_encrypted_object(
5842    payload: &[u8],
5843    context: ObjectEncryptionContext<'_>,
5844    expected_extent: ObjectExtent,
5845    options: WriterOptions,
5846) -> Result<Vec<SerializedBlockRecord>, FormatError> {
5847    let planned = plan_encrypted_object(
5848        payload.len(),
5849        context.data_shard_max,
5850        context.class_parity_shard_max,
5851        options,
5852    )?;
5853    if planned.parity_block_count != 0 || expected_extent.parity_block_count != 0 {
5854        return Err(FormatError::WriterInvariant(
5855            "zero-parity serialization received a parity object",
5856        ));
5857    }
5858    if planned.data_block_count != expected_extent.data_block_count
5859        || planned.encrypted_size != expected_extent.encrypted_size
5860    {
5861        return Err(FormatError::WriterInvariant(
5862            "encrypted object did not match planned sizing",
5863        ));
5864    }
5865
5866    let block_size = options.block_size as usize;
5867    let encrypted = encrypt_object_payload(payload, context, options)?;
5868    if encrypted.len() != expected_extent.encrypted_size as usize
5869        || encrypted.len() % block_size != 0
5870    {
5871        return Err(FormatError::WriterInvariant(
5872            "encrypted object did not match planned sizing",
5873        ));
5874    }
5875    let data_block_count = encrypted.len() / block_size;
5876    if data_block_count == 0 || data_block_count != expected_extent.data_block_count as usize {
5877        return Err(FormatError::WriterInvariant(
5878            "encrypted object did not match planned sizing",
5879        ));
5880    }
5881
5882    let mut records = Vec::with_capacity(data_block_count);
5883    for (index, chunk) in encrypted.chunks(block_size).enumerate() {
5884        let block_index = checked_u64_add(
5885            expected_extent.first_block_index,
5886            index as u64,
5887            "BlockRecord",
5888        )?;
5889        let flags = if index + 1 == data_block_count {
5890            0x01
5891        } else {
5892            0
5893        };
5894        records.push(SerializedBlockRecord {
5895            block_index,
5896            bytes: BlockRecord::to_bytes_from_parts(block_index, context.data_kind, flags, chunk),
5897        });
5898    }
5899    Ok(records)
5900}
5901
5902fn encrypt_object_payload(
5903    payload: &[u8],
5904    context: ObjectEncryptionContext<'_>,
5905    options: WriterOptions,
5906) -> Result<Vec<u8>, FormatError> {
5907    let block_size = options.block_size as usize;
5908    let padded = suffix_pad_for_aead(payload, options.aead_algo.tag_len(), block_size)?;
5909    if matches!(options.aead_algo, AeadAlgo::None) {
5910        return Ok(padded);
5911    }
5912    let nonce = derive_nonce(
5913        context.nonce_seed,
5914        context.domain,
5915        context.archive_uuid,
5916        context.session_id,
5917        context.counter,
5918        options.aead_algo.nonce_len(),
5919    )?;
5920    let aad = build_aad(
5921        context.domain,
5922        context.archive_uuid,
5923        context.session_id,
5924        context.counter,
5925    )?;
5926    aead_encrypt(options.aead_algo, context.key, &nonce, &aad, &padded)
5927}
5928
5929fn validate_planned_object(
5930    object: &EncryptedObject,
5931    expected: PlannedEncryptedObject,
5932) -> Result<(), FormatError> {
5933    if object.data_block_count != expected.data_block_count
5934        || object.parity_block_count != expected.parity_block_count
5935        || object.encrypted_size != expected.encrypted_size
5936    {
5937        return Err(FormatError::WriterInvariant(
5938            "encrypted object did not match planned sizing",
5939        ));
5940    }
5941    Ok(())
5942}
5943
5944fn validate_planned_extent(
5945    object: &EncryptedObject,
5946    expected: ObjectExtent,
5947) -> Result<(), FormatError> {
5948    validate_planned_object(
5949        object,
5950        PlannedEncryptedObject {
5951            data_block_count: expected.data_block_count,
5952            parity_block_count: expected.parity_block_count,
5953            encrypted_size: expected.encrypted_size,
5954        },
5955    )?;
5956    if object.first_block_index != expected.first_block_index {
5957        return Err(FormatError::WriterInvariant(
5958            "encrypted object did not match planned extent",
5959        ));
5960    }
5961    Ok(())
5962}
5963
5964fn map_metadata_encrypt_error(error: FormatError, kind: MetadataObjectKind) -> FormatError {
5965    match error {
5966        FormatError::WriterUnsupported("encrypted object exceeds u32 size limit")
5967        | FormatError::WriterUnsupported("encrypted object exceeds its data shard class maximum")
5968        | FormatError::WriterUnsupported(
5969            "encrypted object exceeds its parity shard class maximum",
5970        )
5971        | FormatError::WriterUnsupported("encrypted object exceeds ReedSolomonGF16 shard limit") => {
5972            kind.too_large_error()
5973        }
5974        other => other,
5975    }
5976}
5977
5978#[derive(Debug, Clone, Copy)]
5979struct RootAuthFooterBuildInput<'a> {
5980    archive_uuid: [u8; 16],
5981    session_id: [u8; 16],
5982    volume_format_rev: u16,
5983    options: WriterOptions,
5984    crypto_header: &'a [u8],
5985    volume_zero_manifest: &'a [u8; MANIFEST_FOOTER_LEN],
5986    index_root_plaintext: &'a [u8],
5987    index_root_extent: ObjectExtent,
5988    dictionary_extent: Option<(ObjectExtent, u32)>,
5989    shard_entries: &'a [ShardEntry],
5990    payload_objects: &'a [PayloadObject],
5991    directory_hint_entries: &'a [DirectoryHintShardEntry],
5992    data_leaf_hashes: &'a [(u64, [u8; 32])],
5993}
5994
5995fn build_root_auth_footer_from_leaf_hashes(
5996    config: RootAuthWriterConfig<'_>,
5997    authenticator: &mut RootAuthAuthenticator<'_>,
5998    input: RootAuthFooterBuildInput<'_>,
5999) -> Result<Vec<u8>, FormatError> {
6000    let mut sorted_leaf_hashes = input.data_leaf_hashes.to_vec();
6001    sorted_leaf_hashes.sort_by_key(|(block_index, _)| *block_index);
6002    let leaf_hashes = sorted_leaf_hashes
6003        .iter()
6004        .map(|(_, leaf_hash)| *leaf_hash)
6005        .collect::<Vec<_>>();
6006    let total_data_block_count = u64::try_from(leaf_hashes.len())
6007        .map_err(|_| FormatError::WriterUnsupported("root-auth data block count"))?;
6008    let data_block_merkle_root = data_block_merkle_root_from_leaf_hashes_for_revision(
6009        FORMAT_VERSION,
6010        input.volume_format_rev,
6011        &leaf_hashes,
6012    )?;
6013
6014    let parsed_crypto = CryptoHeader::parse(
6015        input.crypto_header,
6016        u32_len(input.crypto_header.len(), "CryptoHeader")?,
6017    )?;
6018    let footer_length = root_auth_footer_wire_length(
6019        config.signer_identity.len(),
6020        config.authenticator_value_length as usize,
6021    )?;
6022    let root_auth_descriptor_digest = root_auth_descriptor_digest_for_revision(
6023        FORMAT_VERSION,
6024        input.volume_format_rev,
6025        config.authenticator_id,
6026        config.signer_identity_type,
6027        config.signer_identity,
6028        config.authenticator_value_length,
6029        footer_length,
6030    )?;
6031    let signer_identity_digest =
6032        signer_identity_digest(config.signer_identity_type, config.signer_identity)?;
6033    let manifest_pre_hmac = manifest_footer_global_pre_hmac_bytes(input.volume_zero_manifest);
6034    let critical_metadata_digest = critical_metadata_digest(CriticalMetadataDigestInputs {
6035        archive_uuid: input.archive_uuid,
6036        session_id: input.session_id,
6037        format_version: FORMAT_VERSION,
6038        volume_format_rev: input.volume_format_rev,
6039        stripe_width: input.options.stripe_width,
6040        total_volumes: input.options.stripe_width,
6041        compression_algo: parsed_crypto.fixed.compression_algo,
6042        aead_algo: parsed_crypto.fixed.aead_algo,
6043        fec_algo: parsed_crypto.fixed.fec_algo,
6044        kdf_algo: parsed_crypto.fixed.kdf_algo,
6045        crypto_header_pre_hmac_bytes: parsed_crypto.hmac_covered_bytes,
6046        chunk_size: parsed_crypto.fixed.chunk_size,
6047        envelope_target_size: parsed_crypto.fixed.envelope_target_size,
6048        block_size: parsed_crypto.fixed.block_size,
6049        fec_data_shards: parsed_crypto.fixed.fec_data_shards,
6050        fec_parity_shards: parsed_crypto.fixed.fec_parity_shards,
6051        index_fec_data_shards: parsed_crypto.fixed.index_fec_data_shards,
6052        index_fec_parity_shards: parsed_crypto.fixed.index_fec_parity_shards,
6053        index_root_fec_data_shards: parsed_crypto.fixed.index_root_fec_data_shards,
6054        index_root_fec_parity_shards: parsed_crypto.fixed.index_root_fec_parity_shards,
6055        volume_loss_tolerance: parsed_crypto.fixed.volume_loss_tolerance,
6056        bit_rot_buffer_pct: parsed_crypto.fixed.bit_rot_buffer_pct,
6057        has_dictionary: parsed_crypto.fixed.has_dictionary,
6058        manifest_footer_global_pre_hmac_bytes: &manifest_pre_hmac,
6059        index_root_first_block: input.index_root_extent.first_block_index,
6060        index_root_data_block_count: input.index_root_extent.data_block_count,
6061        index_root_parity_block_count: input.index_root_extent.parity_block_count,
6062        index_root_encrypted_size: input.index_root_extent.encrypted_size,
6063        index_root_decompressed_size: u32_len(input.index_root_plaintext.len(), "IndexRoot")?,
6064        root_auth_descriptor_digest,
6065    })?;
6066    let index_digest = index_digest_for_revision(
6067        FORMAT_VERSION,
6068        input.volume_format_rev,
6069        input.index_root_plaintext,
6070    )?;
6071    let fec_layout_rows = writer_fec_layout_rows_from_extents(
6072        input.index_root_extent,
6073        u32_len(input.index_root_plaintext.len(), "IndexRoot")?,
6074        input.dictionary_extent,
6075        input.shard_entries,
6076        input.payload_objects,
6077        input.directory_hint_entries,
6078    );
6079    let expected_data_block_count = fec_layout_rows.iter().try_fold(0u64, |total, row| {
6080        if row.present {
6081            checked_u64_add(
6082                total,
6083                row.data_block_count as u64,
6084                "root-auth data block count",
6085            )
6086        } else {
6087            Ok(total)
6088        }
6089    })?;
6090    if expected_data_block_count != total_data_block_count {
6091        return Err(FormatError::WriterInvariant(
6092            "root-auth data block count does not match FEC layout",
6093        ));
6094    }
6095    let fec_layout_digest =
6096        fec_layout_digest_for_revision(FORMAT_VERSION, input.volume_format_rev, &fec_layout_rows)?;
6097    let archive_root = archive_root_for_revision(ArchiveRootInputs {
6098        archive_uuid: input.archive_uuid,
6099        session_id: input.session_id,
6100        format_version: FORMAT_VERSION,
6101        volume_format_rev: input.volume_format_rev,
6102        compression_algo: parsed_crypto.fixed.compression_algo,
6103        aead_algo: parsed_crypto.fixed.aead_algo,
6104        fec_algo: parsed_crypto.fixed.fec_algo,
6105        kdf_algo: parsed_crypto.fixed.kdf_algo,
6106        critical_metadata_digest,
6107        index_digest,
6108        fec_layout_digest,
6109        total_data_block_count,
6110        data_block_merkle_root,
6111        root_auth_descriptor_digest,
6112        signer_identity_digest,
6113    })?;
6114    let authenticator_value = authenticator(&RootAuthSigningRequest {
6115        root_auth_spec_id: root_auth_spec_id_for_revision(FORMAT_VERSION, input.volume_format_rev)?,
6116        archive_uuid: input.archive_uuid,
6117        session_id: input.session_id,
6118        archive_root,
6119    })?;
6120    if authenticator_value.len() != config.authenticator_value_length as usize {
6121        return Err(FormatError::WriterUnsupported(
6122            "root-auth authenticator length mismatch",
6123        ));
6124    }
6125
6126    RootAuthFooterV1 {
6127        archive_uuid: input.archive_uuid,
6128        session_id: input.session_id,
6129        format_version: FORMAT_VERSION,
6130        volume_format_rev: input.volume_format_rev,
6131        authenticator_id: config.authenticator_id,
6132        signer_identity_type: config.signer_identity_type,
6133        signer_identity_bytes: config.signer_identity.to_vec(),
6134        authenticator_value,
6135        total_data_block_count,
6136        critical_metadata_digest,
6137        index_digest,
6138        fec_layout_digest,
6139        data_block_merkle_root,
6140        signer_identity_digest,
6141        archive_root,
6142        footer_crc32c: 0,
6143    }
6144    .to_bytes()
6145}
6146
6147fn writer_fec_layout_rows_from_extents(
6148    index_root_extent: ObjectExtent,
6149    index_root_plain_size: u32,
6150    dictionary_extent: Option<(ObjectExtent, u32)>,
6151    shard_entries: &[ShardEntry],
6152    payload_objects: &[PayloadObject],
6153    directory_hint_entries: &[DirectoryHintShardEntry],
6154) -> Vec<FecLayoutObjectRow> {
6155    let mut rows = Vec::new();
6156    rows.push(FecLayoutObjectRow {
6157        object_class: 1,
6158        present: true,
6159        object_id: 0,
6160        first_block_index: index_root_extent.first_block_index,
6161        data_block_count: index_root_extent.data_block_count,
6162        parity_block_count: index_root_extent.parity_block_count,
6163        encrypted_size: index_root_extent.encrypted_size,
6164        plain_size: index_root_plain_size,
6165    });
6166    if let Some((dictionary, decompressed_size)) = dictionary_extent {
6167        rows.push(FecLayoutObjectRow {
6168            object_class: 2,
6169            present: true,
6170            object_id: 0,
6171            first_block_index: dictionary.first_block_index,
6172            data_block_count: dictionary.data_block_count,
6173            parity_block_count: dictionary.parity_block_count,
6174            encrypted_size: dictionary.encrypted_size,
6175            plain_size: decompressed_size,
6176        });
6177    } else {
6178        rows.push(FecLayoutObjectRow {
6179            object_class: 2,
6180            present: false,
6181            object_id: 0,
6182            first_block_index: 0,
6183            data_block_count: 0,
6184            parity_block_count: 0,
6185            encrypted_size: 0,
6186            plain_size: 0,
6187        });
6188    }
6189    for entry in shard_entries {
6190        rows.push(FecLayoutObjectRow {
6191            object_class: 3,
6192            present: true,
6193            object_id: entry.shard_index,
6194            first_block_index: entry.first_block_index,
6195            data_block_count: entry.data_block_count,
6196            parity_block_count: entry.parity_block_count,
6197            encrypted_size: entry.encrypted_size,
6198            plain_size: entry.decompressed_size,
6199        });
6200    }
6201    for payload in payload_objects {
6202        rows.push(FecLayoutObjectRow {
6203            object_class: 4,
6204            present: true,
6205            object_id: payload.envelope_index,
6206            first_block_index: payload.object.first_block_index,
6207            data_block_count: payload.object.data_block_count,
6208            parity_block_count: payload.object.parity_block_count,
6209            encrypted_size: payload.object.encrypted_size,
6210            plain_size: payload.plaintext_size,
6211        });
6212    }
6213    for entry in directory_hint_entries {
6214        rows.push(FecLayoutObjectRow {
6215            object_class: 5,
6216            present: true,
6217            object_id: entry.hint_shard_index,
6218            first_block_index: entry.first_block_index,
6219            data_block_count: entry.data_block_count,
6220            parity_block_count: entry.parity_block_count,
6221            encrypted_size: entry.encrypted_size,
6222            plain_size: entry.decompressed_size,
6223        });
6224    }
6225    rows
6226}
6227
6228fn manifest_footer_global_pre_hmac_bytes(manifest_footer: &[u8; MANIFEST_FOOTER_LEN]) -> [u8; 104] {
6229    let mut bytes = [0u8; 104];
6230    bytes.copy_from_slice(&manifest_footer[..104]);
6231    bytes[36..40].fill(0);
6232    bytes
6233}
6234
6235fn root_auth_footer_wire_length(
6236    signer_identity_len: usize,
6237    authenticator_value_len: usize,
6238) -> Result<u32, FormatError> {
6239    validate_root_auth_variable_lengths_for_writer(signer_identity_len, authenticator_value_len)?;
6240    let len = crate::format::ROOT_AUTH_FOOTER_FIXED_LEN
6241        .checked_add(signer_identity_len)
6242        .and_then(|value| value.checked_add(authenticator_value_len))
6243        .and_then(|value| value.checked_add(4))
6244        .ok_or(FormatError::WriterUnsupported(
6245            "RootAuthFooterV1 length overflow",
6246        ))?;
6247    if len > READER_MAX_ROOT_AUTH_FOOTER_LEN as usize {
6248        return Err(FormatError::ReaderResourceLimitExceeded {
6249            field: "RootAuthFooterV1 length",
6250            cap: READER_MAX_ROOT_AUTH_FOOTER_LEN as u64,
6251            actual: len as u64,
6252        });
6253    }
6254    u32::try_from(len).map_err(|_| FormatError::WriterUnsupported("RootAuthFooterV1 length"))
6255}
6256
6257fn validate_root_auth_writer_config(config: RootAuthWriterConfig<'_>) -> Result<(), FormatError> {
6258    root_auth_footer_wire_length(
6259        config.signer_identity.len(),
6260        config.authenticator_value_length as usize,
6261    )?;
6262    Ok(())
6263}
6264
6265fn validate_root_auth_variable_lengths_for_writer(
6266    signer_identity_len: usize,
6267    authenticator_value_len: usize,
6268) -> Result<(), FormatError> {
6269    if signer_identity_len > READER_MAX_ROOT_AUTH_SIGNER_IDENTITY_LEN as usize {
6270        return Err(FormatError::ReaderResourceLimitExceeded {
6271            field: "RootAuthFooterV1 signer identity length",
6272            cap: READER_MAX_ROOT_AUTH_SIGNER_IDENTITY_LEN as u64,
6273            actual: signer_identity_len as u64,
6274        });
6275    }
6276    if authenticator_value_len > READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as usize {
6277        return Err(FormatError::ReaderResourceLimitExceeded {
6278            field: "RootAuthFooterV1 authenticator value length",
6279            cap: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as u64,
6280            actual: authenticator_value_len as u64,
6281        });
6282    }
6283    Ok(())
6284}
6285
6286#[allow(clippy::too_many_arguments)]
6287fn build_manifest_footer(
6288    subkeys: &Subkeys,
6289    aead_algo: AeadAlgo,
6290    volume_format_rev: u16,
6291    archive_uuid: [u8; 16],
6292    session_id: [u8; 16],
6293    volume_index: u32,
6294    total_volumes: u32,
6295    index_root_extent: &ObjectExtent,
6296    index_root_decompressed_size: usize,
6297) -> Result<[u8; MANIFEST_FOOTER_LEN], FormatError> {
6298    let mut footer = ManifestFooter {
6299        archive_uuid,
6300        session_id,
6301        volume_index,
6302        is_authoritative: 1,
6303        total_volumes,
6304        index_root_first_block: index_root_extent.first_block_index,
6305        index_root_data_block_count: index_root_extent.data_block_count,
6306        index_root_parity_block_count: index_root_extent.parity_block_count,
6307        index_root_encrypted_size: index_root_extent.encrypted_size,
6308        index_root_decompressed_size: u32_len(index_root_decompressed_size, "IndexRoot")?,
6309        manifest_hmac: [0u8; 32],
6310    };
6311    let mut bytes = footer.to_bytes();
6312    footer.manifest_hmac = compute_integrity_tag(
6313        HmacDomain::ManifestFooter,
6314        aead_algo,
6315        volume_format_rev,
6316        Some(&subkeys.mac_key),
6317        &archive_uuid,
6318        &session_id,
6319        &bytes[..104],
6320    )?;
6321    bytes = footer.to_bytes();
6322    Ok(bytes)
6323}
6324
6325#[derive(Debug, Clone, Copy)]
6326struct VolumeTrailerBuildInput<'a> {
6327    subkeys: &'a Subkeys,
6328    aead_algo: AeadAlgo,
6329    volume_format_rev: u16,
6330    archive_uuid: [u8; 16],
6331    session_id: [u8; 16],
6332    volume_index: u32,
6333    block_count: u64,
6334    bytes_written: u64,
6335    manifest_footer_offset: u64,
6336    closed_at_ns: i64,
6337    root_auth_footer: Option<(u64, u32)>,
6338}
6339
6340fn build_volume_trailer(
6341    input: VolumeTrailerBuildInput<'_>,
6342) -> Result<[u8; VOLUME_TRAILER_LEN], FormatError> {
6343    let (root_auth_footer_offset, root_auth_footer_length, root_auth_flags) =
6344        match input.root_auth_footer {
6345            Some((offset, length)) => (offset, length, 0x0000_0001),
6346            None => (0, 0, 0),
6347        };
6348    let mut trailer = VolumeTrailer {
6349        archive_uuid: input.archive_uuid,
6350        session_id: input.session_id,
6351        volume_index: input.volume_index,
6352        block_count: input.block_count,
6353        bytes_written: input.bytes_written,
6354        manifest_footer_offset: input.manifest_footer_offset,
6355        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
6356        closed_at_ns: input.closed_at_ns,
6357        root_auth_footer_offset,
6358        root_auth_footer_length,
6359        root_auth_flags,
6360        trailer_hmac: [0u8; 32],
6361    };
6362    let mut bytes = trailer.to_bytes();
6363    trailer.trailer_hmac = compute_integrity_tag(
6364        HmacDomain::VolumeTrailer,
6365        input.aead_algo,
6366        input.volume_format_rev,
6367        Some(&input.subkeys.mac_key),
6368        &input.archive_uuid,
6369        &input.session_id,
6370        &bytes[..96],
6371    )?;
6372    bytes = trailer.to_bytes();
6373    Ok(bytes)
6374}
6375
6376struct BuiltCmra {
6377    bytes: Vec<u8>,
6378    shard_size: u32,
6379    data_shard_count: u16,
6380    parity_shard_count: u16,
6381    image_length: u32,
6382    image_sha256: [u8; 32],
6383}
6384
6385#[derive(Debug, Clone, Copy)]
6386struct CmraBuildInput<'a> {
6387    volume_format_rev: u16,
6388    volume_header_bytes: &'a [u8; VOLUME_HEADER_LEN],
6389    crypto_header: &'a [u8],
6390    block_count: u64,
6391    block_records_offset: u64,
6392    manifest_footer_offset: u64,
6393    manifest_footer: &'a [u8; MANIFEST_FOOTER_LEN],
6394    root_auth_footer_offset: Option<u64>,
6395    root_auth_footer: Option<&'a [u8]>,
6396    key_wrap_table: Option<&'a [u8]>,
6397    trailer_offset: u64,
6398    trailer: &'a [u8; VOLUME_TRAILER_LEN],
6399    cmra_offset: u64,
6400    options: WriterOptions,
6401    archive_uuid: [u8; 16],
6402    session_id: [u8; 16],
6403    volume_index: u32,
6404}
6405
6406fn build_v41_cmra(input: CmraBuildInput<'_>) -> Result<BuiltCmra, FormatError> {
6407    let block_record_len = input.options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
6408    let crypto_end = VOLUME_HEADER_LEN as u64 + input.crypto_header.len() as u64;
6409    let block_records_offset = input.block_records_offset;
6410    let key_wrap_table = input
6411        .key_wrap_table
6412        .map(|table| {
6413            if table.is_empty() {
6414                return Err(FormatError::WriterInvariant(
6415                    "KeyWrapTableV1 must include at least one recipient record",
6416                ));
6417            }
6418            let key_wrap_table_length = u32_len(table.len(), "KeyWrapTableV1 table_length")?;
6419            Ok((table, key_wrap_table_length))
6420        })
6421        .transpose()?;
6422    if input.volume_format_rev != VOLUME_FORMAT_REV_45 && key_wrap_table.is_some() {
6423        return Err(FormatError::WriterInvariant(
6424            "KeyWrapTableV1 requires volume_format_rev 45",
6425        ));
6426    }
6427    let key_wrap_table_length = key_wrap_table.map(|(_, length)| u64::from(length));
6428    let expected_block_records_offset = match key_wrap_table_length {
6429        Some(length) => checked_u64_add(crypto_end, length, "KeyWrapTableV1")?,
6430        None => crypto_end,
6431    };
6432    if block_records_offset != expected_block_records_offset {
6433        return Err(FormatError::WriterInvariant(
6434            "CMRA block records offset does not match key-wrap table layout",
6435        ));
6436    }
6437    let block_records_length = checked_u64_mul(
6438        input.block_count,
6439        block_record_len,
6440        "CMRA BlockRecord length overflow",
6441    )?;
6442    let manifest_end = input
6443        .manifest_footer_offset
6444        .checked_add(MANIFEST_FOOTER_LEN as u64)
6445        .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?;
6446    let root_auth_footer_length = input
6447        .root_auth_footer
6448        .map(|footer| u32_len(footer.len(), "RootAuthFooterV1"))
6449        .transpose()?;
6450    match (input.root_auth_footer_offset, root_auth_footer_length) {
6451        (Some(offset), Some(length)) => {
6452            if manifest_end != offset
6453                || offset
6454                    .checked_add(length as u64)
6455                    .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?
6456                    != input.trailer_offset
6457            {
6458                return Err(FormatError::WriterInvariant(
6459                    "RootAuthFooter does not sit between ManifestFooter and VolumeTrailer",
6460                ));
6461            }
6462        }
6463        (None, None) => {
6464            if manifest_end != input.trailer_offset {
6465                return Err(FormatError::WriterInvariant(
6466                    "ManifestFooter does not end at VolumeTrailer",
6467                ));
6468            }
6469        }
6470        _ => {
6471            return Err(FormatError::WriterInvariant(
6472                "RootAuthFooter offset/bytes mismatch",
6473            ));
6474        }
6475    }
6476    let body_bytes_before_cmra = input
6477        .trailer_offset
6478        .checked_add(VOLUME_TRAILER_LEN as u64)
6479        .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?;
6480    if body_bytes_before_cmra != input.cmra_offset {
6481        return Err(FormatError::WriterInvariant(
6482            "CMRA does not start after VolumeTrailer",
6483        ));
6484    }
6485
6486    let mut regions = vec![
6487        SerializedRegion {
6488            region_type: 1,
6489            offset: 0,
6490            bytes: input.volume_header_bytes.to_vec(),
6491        },
6492        SerializedRegion {
6493            region_type: 2,
6494            offset: VOLUME_HEADER_LEN as u64,
6495            bytes: input.crypto_header.to_vec(),
6496        },
6497    ];
6498    if let Some((table, _)) = key_wrap_table {
6499        regions.push(SerializedRegion {
6500            region_type: 6,
6501            offset: crypto_end,
6502            bytes: table.to_vec(),
6503        });
6504    }
6505    regions.push(SerializedRegion {
6506        region_type: 3,
6507        offset: input.manifest_footer_offset,
6508        bytes: input.manifest_footer.to_vec(),
6509    });
6510    if let (Some(offset), Some(footer)) = (input.root_auth_footer_offset, input.root_auth_footer) {
6511        regions.push(SerializedRegion {
6512            region_type: 4,
6513            offset,
6514            bytes: footer.to_vec(),
6515        });
6516    }
6517    regions.push(SerializedRegion {
6518        region_type: 5,
6519        offset: input.trailer_offset,
6520        bytes: input.trailer.to_vec(),
6521    });
6522    let root_auth_flag = if input.root_auth_footer.is_some() {
6523        0x0000_0001
6524    } else {
6525        0
6526    };
6527    let key_wrap_flag = if key_wrap_table.is_some() {
6528        0x0000_0002
6529    } else {
6530        0
6531    };
6532    let image = CriticalMetadataImage {
6533        volume_format_rev: input.volume_format_rev,
6534        archive_uuid: input.archive_uuid,
6535        session_id: input.session_id,
6536        volume_index: input.volume_index,
6537        stripe_width: input.options.stripe_width,
6538        layout_flags: root_auth_flag | key_wrap_flag,
6539        volume_header_offset: 0,
6540        volume_header_length: VOLUME_HEADER_LEN as u32,
6541        crypto_header_offset: VOLUME_HEADER_LEN as u64,
6542        crypto_header_length: u32_len(input.crypto_header.len(), "CryptoHeader")?,
6543        key_wrap_table_offset: key_wrap_table.map(|_| crypto_end).unwrap_or(0),
6544        key_wrap_table_length: key_wrap_table.map(|(_, length)| length).unwrap_or(0),
6545        block_records_offset,
6546        block_records_length,
6547        block_count: input.block_count,
6548        manifest_footer_offset: input.manifest_footer_offset,
6549        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
6550        root_auth_footer_offset: input.root_auth_footer_offset.unwrap_or(0),
6551        root_auth_footer_length: root_auth_footer_length.unwrap_or(0),
6552        volume_trailer_offset: input.trailer_offset,
6553        volume_trailer_length: VOLUME_TRAILER_LEN as u32,
6554        body_bytes_before_cmra,
6555        volume_header_sha256: sha256_bytes(input.volume_header_bytes),
6556        crypto_header_sha256: sha256_bytes(input.crypto_header),
6557        key_wrap_table_sha256: key_wrap_table
6558            .map(|(table, _)| sha256_bytes(table))
6559            .unwrap_or([0u8; 32]),
6560        manifest_footer_sha256: sha256_bytes(input.manifest_footer),
6561        root_auth_footer_sha256: input
6562            .root_auth_footer
6563            .map(sha256_bytes)
6564            .unwrap_or([0u8; 32]),
6565        volume_trailer_sha256: sha256_bytes(input.trailer),
6566        regions,
6567    };
6568    let image_bytes = image.to_bytes()?;
6569    let image_sha256 = sha256_bytes(&image_bytes);
6570    let data_shard_count = ceil_div(image_bytes.len() as u64, CMRA_SHARD_SIZE as u64)?;
6571    let data_shard_count_u16 = u16::try_from(data_shard_count)
6572        .map_err(|_| FormatError::WriterUnsupported("CMRA data shard count"))?;
6573    let parity_lower = cmra_min_parity_shards(data_shard_count, input.options.bit_rot_buffer_pct)?;
6574    let parity_upper = cmra_min_parity_shards(data_shard_count, READER_MAX_CMRA_PARITY_PCT as u8)?;
6575    if parity_lower > parity_upper {
6576        return Err(FormatError::WriterUnsupported("CMRA parity bounds"));
6577    }
6578    let parity_shard_count_u16 = u16::try_from(parity_lower)
6579        .map_err(|_| FormatError::WriterUnsupported("CMRA parity shard count"))?;
6580
6581    let mut data_shards = Vec::with_capacity(data_shard_count as usize);
6582    for idx in 0..data_shard_count as usize {
6583        let start = idx * CMRA_SHARD_SIZE;
6584        let end = (start + CMRA_SHARD_SIZE).min(image_bytes.len());
6585        let mut shard = vec![0u8; CMRA_SHARD_SIZE];
6586        if start < image_bytes.len() {
6587            shard[..end - start].copy_from_slice(&image_bytes[start..end]);
6588        }
6589        data_shards.push(shard);
6590    }
6591    let parity_shards = encode_parity_gf16(&data_shards, parity_shard_count_u16 as usize)?;
6592
6593    let header = CriticalMetadataRecoveryHeader {
6594        shard_size: CMRA_SHARD_SIZE as u32,
6595        data_shard_count: data_shard_count_u16,
6596        parity_shard_count: parity_shard_count_u16,
6597        image_length: u32_len(image_bytes.len(), "CriticalMetadataImageV1")?,
6598        archive_uuid_hint: input.archive_uuid,
6599        session_id_hint: input.session_id,
6600        volume_index_hint: input.volume_index,
6601        image_sha256,
6602        header_crc32c: 0,
6603    };
6604    let mut cmra = Vec::new();
6605    cmra.extend_from_slice(&header.to_bytes());
6606    for (idx, payload) in data_shards.into_iter().enumerate() {
6607        let payload_len = if idx + 1 == data_shard_count as usize {
6608            let final_len = image_bytes.len() - idx * CMRA_SHARD_SIZE;
6609            if final_len == 0 {
6610                CMRA_SHARD_SIZE
6611            } else {
6612                final_len
6613            }
6614        } else {
6615            CMRA_SHARD_SIZE
6616        };
6617        cmra.extend_from_slice(
6618            &CriticalMetadataRecoveryShard {
6619                shard_index: u16::try_from(idx)
6620                    .map_err(|_| FormatError::WriterUnsupported("CMRA shard index"))?,
6621                shard_role: 0,
6622                shard_payload_length: u32_len(payload_len, "CMRA shard payload")?,
6623                payload,
6624                shard_crc32c: 0,
6625            }
6626            .to_bytes(CMRA_SHARD_SIZE)?,
6627        );
6628    }
6629    for (idx, payload) in parity_shards.into_iter().enumerate() {
6630        let shard_index = data_shard_count
6631            .checked_add(idx as u64)
6632            .ok_or(FormatError::WriterUnsupported("CMRA shard index overflow"))?;
6633        cmra.extend_from_slice(
6634            &CriticalMetadataRecoveryShard {
6635                shard_index: u16::try_from(shard_index)
6636                    .map_err(|_| FormatError::WriterUnsupported("CMRA shard index"))?,
6637                shard_role: 1,
6638                shard_payload_length: CMRA_SHARD_SIZE as u32,
6639                payload,
6640                shard_crc32c: 0,
6641            }
6642            .to_bytes(CMRA_SHARD_SIZE)?,
6643        );
6644    }
6645
6646    Ok(BuiltCmra {
6647        bytes: cmra,
6648        shard_size: CMRA_SHARD_SIZE as u32,
6649        data_shard_count: data_shard_count_u16,
6650        parity_shard_count: parity_shard_count_u16,
6651        image_length: u32_len(image_bytes.len(), "CriticalMetadataImageV1")?,
6652        image_sha256,
6653    })
6654}
6655
6656fn cmra_min_parity_shards(data_shard_count: u64, pct: u8) -> Result<u64, FormatError> {
6657    let by_pct = ceil_div(
6658        checked_u64_mul(data_shard_count, pct as u64, "CMRA parity overflow")?,
6659        100,
6660    )?;
6661    Ok(2u64.max(by_pct))
6662}
6663
6664fn compute_object_parity(
6665    data_block_count: u64,
6666    options: WriterOptions,
6667    class_parity_shard_max: u32,
6668) -> Result<u32, FormatError> {
6669    let computed = compute_parity(data_block_count, options)?;
6670    if computed > class_parity_shard_max {
6671        return Err(FormatError::WriterUnsupported(
6672            "encrypted object exceeds its parity shard class maximum",
6673        ));
6674    }
6675    Ok(computed)
6676}
6677
6678fn validate_object_shard_total(
6679    data_block_count: u32,
6680    parity_block_count: u32,
6681) -> Result<(), FormatError> {
6682    let total = checked_u64_add(
6683        data_block_count as u64,
6684        parity_block_count as u64,
6685        "encrypted object shard total overflow",
6686    )?;
6687    if total > MAX_REED_SOLOMON_GF16_SHARDS {
6688        return Err(FormatError::WriterUnsupported(
6689            "encrypted object exceeds ReedSolomonGF16 shard limit",
6690        ));
6691    }
6692    Ok(())
6693}
6694
6695fn compute_parity_u16(
6696    data_block_count: u64,
6697    options: WriterOptions,
6698    field: &'static str,
6699) -> Result<u16, FormatError> {
6700    let parity = compute_parity(data_block_count, options)?;
6701    u16::try_from(parity).map_err(|_| FormatError::WriterUnsupported(field))
6702}
6703
6704fn compute_parity(data_block_count: u64, options: WriterOptions) -> Result<u32, FormatError> {
6705    let min_parity = if options.volume_loss_tolerance > 0 || options.bit_rot_buffer_pct > 0 {
6706        1u64
6707    } else {
6708        0u64
6709    };
6710    let mut parity = 0u64;
6711    for _ in 0..100 {
6712        let total = data_block_count
6713            .checked_add(parity)
6714            .ok_or(FormatError::WriterUnsupported("parity total overflow"))?;
6715        let by_volume = checked_u64_mul(
6716            options.volume_loss_tolerance as u64,
6717            ceil_div(total, options.stripe_width as u64)?,
6718            "volume-loss parity overflow",
6719        )?;
6720        let by_bitrot = ceil_div(
6721            checked_u64_mul(
6722                total,
6723                options.bit_rot_buffer_pct as u64,
6724                "bit-rot parity overflow",
6725            )?,
6726            100,
6727        )?;
6728        let next = by_volume
6729            .checked_add(by_bitrot)
6730            .ok_or(FormatError::WriterUnsupported("parity overflow"))?
6731            .max(min_parity);
6732        if next == parity {
6733            return u32::try_from(next).map_err(|_| FormatError::WriterUnsupported("parity count"));
6734        }
6735        parity = next;
6736    }
6737    Err(FormatError::WriterUnsupported(
6738        "parity calculation did not converge",
6739    ))
6740}
6741
6742fn ceil_div(numerator: u64, denominator: u64) -> Result<u64, FormatError> {
6743    if denominator == 0 {
6744        return Err(FormatError::WriterUnsupported("division by zero"));
6745    }
6746    numerator
6747        .checked_add(denominator - 1)
6748        .ok_or(FormatError::WriterUnsupported("ceiling division overflow"))
6749        .map(|value| value / denominator)
6750}
6751
6752fn checked_u64_mul(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
6753    lhs.checked_mul(rhs)
6754        .ok_or(FormatError::WriterUnsupported(field))
6755}
6756
6757#[allow(clippy::too_many_arguments)]
6758fn build_bootstrap_sidecar(
6759    subkeys: &Subkeys,
6760    aead_algo: AeadAlgo,
6761    volume_format_rev: u16,
6762    archive_uuid: [u8; 16],
6763    session_id: [u8; 16],
6764    manifest_footer: &[u8; MANIFEST_FOOTER_LEN],
6765    index_root_records: &[BlockRecord],
6766    dictionary_records: Option<&[BlockRecord]>,
6767) -> Result<Vec<u8>, FormatError> {
6768    let index_records_len = index_root_records.iter().try_fold(0usize, |sum, record| {
6769        checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
6770    })?;
6771    let dictionary_records_len = dictionary_records
6772        .unwrap_or(&[])
6773        .iter()
6774        .try_fold(0usize, |sum, record| {
6775            checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
6776        })?;
6777    let manifest_offset = BOOTSTRAP_SIDECAR_HEADER_LEN as u64;
6778    let index_root_offset = manifest_offset + MANIFEST_FOOTER_LEN as u64;
6779    let dictionary_offset = if dictionary_records.is_some() {
6780        index_root_offset + index_records_len as u64
6781    } else {
6782        0
6783    };
6784    let mut header = BootstrapSidecarHeader {
6785        archive_uuid,
6786        session_id,
6787        flags: if dictionary_records.is_some() {
6788            0x07
6789        } else {
6790            0x03
6791        },
6792        manifest_footer_offset: manifest_offset,
6793        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
6794        index_root_records_offset: index_root_offset,
6795        index_root_records_length: index_records_len as u64,
6796        dictionary_records_offset: dictionary_offset,
6797        dictionary_records_length: dictionary_records_len as u64,
6798        sidecar_hmac: [0u8; 32],
6799        header_crc32c: 0,
6800    };
6801    let mut header_bytes = header.to_bytes();
6802    header.sidecar_hmac = compute_integrity_tag(
6803        HmacDomain::BootstrapSidecar,
6804        aead_algo,
6805        volume_format_rev,
6806        Some(&subkeys.mac_key),
6807        &archive_uuid,
6808        &session_id,
6809        &header_bytes[..92],
6810    )?;
6811    header_bytes = header.to_bytes();
6812
6813    let mut sidecar = Vec::with_capacity(
6814        BOOTSTRAP_SIDECAR_HEADER_LEN
6815            + MANIFEST_FOOTER_LEN
6816            + index_records_len
6817            + dictionary_records_len,
6818    );
6819    sidecar.extend_from_slice(&header_bytes);
6820    sidecar.extend_from_slice(manifest_footer);
6821    for record in index_root_records {
6822        sidecar.extend_from_slice(&record.to_bytes());
6823    }
6824    if let Some(dictionary_records) = dictionary_records {
6825        for record in dictionary_records {
6826            sidecar.extend_from_slice(&record.to_bytes());
6827        }
6828    }
6829    Ok(sidecar)
6830}
6831
6832struct StreamingMemberReader<'a> {
6833    sections: Vec<StreamingMemberSection<'a>>,
6834    section_index: usize,
6835    pushback: Vec<u8>,
6836}
6837
6838struct StreamingMemberSection<'a> {
6839    reader: Option<Box<dyn Read + 'a>>,
6840    opener: Option<SectionOpener<'a>>,
6841    remaining: u64,
6842    remaining_padding: u64,
6843    expected_sha256: Option<[u8; 32]>,
6844    hasher: Sha256,
6845    source_eof_checked: bool,
6846}
6847
6848type SectionOpener<'a> = Box<dyn FnOnce() -> Result<Box<dyn Read + 'a>, ArchiveWriteError> + 'a>;
6849
6850impl<'a> StreamingMemberSection<'a> {
6851    fn bytes(bytes: Vec<u8>) -> Self {
6852        let size = bytes.len() as u64;
6853        Self {
6854            reader: Some(Box::new(Cursor::new(bytes))),
6855            opener: None,
6856            remaining: size,
6857            remaining_padding: 0,
6858            expected_sha256: None,
6859            hasher: Sha256::new(),
6860            source_eof_checked: false,
6861        }
6862    }
6863
6864    fn payload(reader: Box<dyn Read + 'a>, size: u64, expected_sha256: Option<[u8; 32]>) -> Self {
6865        Self {
6866            reader: Some(reader),
6867            opener: None,
6868            remaining: size,
6869            remaining_padding: padding_to_512_u64(size),
6870            expected_sha256,
6871            hasher: Sha256::new(),
6872            source_eof_checked: false,
6873        }
6874    }
6875
6876    fn deferred_payload(
6877        opener: SectionOpener<'a>,
6878        size: u64,
6879        expected_sha256: Option<[u8; 32]>,
6880    ) -> Self {
6881        Self {
6882            reader: None,
6883            opener: Some(opener),
6884            remaining: size,
6885            remaining_padding: padding_to_512_u64(size),
6886            expected_sha256,
6887            hasher: Sha256::new(),
6888            source_eof_checked: false,
6889        }
6890    }
6891
6892    fn reader(&mut self) -> std::io::Result<&mut Box<dyn Read + 'a>> {
6893        if self.reader.is_none() {
6894            let opener = self
6895                .opener
6896                .take()
6897                .ok_or_else(|| std::io::Error::other("streaming member section has no source"))?;
6898            self.reader = Some(opener().map_err(|error| std::io::Error::other(error.to_string()))?);
6899        }
6900        Ok(self.reader.as_mut().expect("reader was initialized"))
6901    }
6902}
6903
6904impl Read for StreamingMemberSection<'_> {
6905    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
6906        if out.is_empty() {
6907            return Ok(0);
6908        }
6909        if self.remaining > 0 {
6910            let limit = out
6911                .len()
6912                .min(usize::try_from(self.remaining).unwrap_or(usize::MAX));
6913            let count = self.reader()?.read(&mut out[..limit])?;
6914            if count == 0 {
6915                return Err(std::io::Error::new(
6916                    std::io::ErrorKind::UnexpectedEof,
6917                    "member source ended before its declared size",
6918                ));
6919            }
6920            self.remaining -= count as u64;
6921            if self.expected_sha256.is_some() {
6922                self.hasher.update(&out[..count]);
6923            }
6924            return Ok(count);
6925        }
6926        if !self.source_eof_checked {
6927            let mut extra = [0u8; 1];
6928            if self.reader()?.read(&mut extra)? != 0 {
6929                return Err(std::io::Error::new(
6930                    std::io::ErrorKind::InvalidData,
6931                    "member source exceeded its declared size",
6932                ));
6933            }
6934            if let Some(expected) = self.expected_sha256 {
6935                let actual: [u8; 32] = self.hasher.clone().finalize().into();
6936                if actual != expected {
6937                    return Err(std::io::Error::new(
6938                        std::io::ErrorKind::InvalidData,
6939                        "auxiliary source changed after metadata capture",
6940                    ));
6941                }
6942            }
6943            self.source_eof_checked = true;
6944            self.reader = None;
6945        }
6946        if self.remaining_padding > 0 {
6947            let count = out
6948                .len()
6949                .min(usize::try_from(self.remaining_padding).unwrap_or(usize::MAX));
6950            out[..count].fill(0);
6951            self.remaining_padding -= count as u64;
6952            return Ok(count);
6953        }
6954        Ok(0)
6955    }
6956}
6957
6958impl<'a> StreamingMemberReader<'a> {
6959    fn new(file: Box<dyn Read + 'a>, prefix: Vec<u8>, file_size: u64) -> Self {
6960        Self {
6961            sections: vec![
6962                StreamingMemberSection::bytes(prefix),
6963                StreamingMemberSection::payload(file, file_size, None),
6964            ],
6965            section_index: 0,
6966            pushback: Vec::new(),
6967        }
6968    }
6969
6970    fn from_source<S: RegularFileSource + ?Sized>(
6971        source: &'a S,
6972        metadata: &PortableFileMetadata,
6973        layout: PrimaryMemberLayout,
6974        primary_size: u64,
6975    ) -> Result<Self, ArchiveWriteError> {
6976        let mut sections = Vec::with_capacity(layout.auxiliary.len() * 2 + 2);
6977        for (ordinal, auxiliary) in layout.auxiliary.into_iter().enumerate() {
6978            sections.push(StreamingMemberSection::bytes(auxiliary.bytes));
6979            let record = metadata.native.auxiliary_records.get(ordinal).ok_or(
6980                FormatError::WriterInvariant("planned auxiliary source ordinal is missing"),
6981            )?;
6982            if record.is_streamed() {
6983                sections.push(StreamingMemberSection::deferred_payload(
6984                    Box::new(move || source.open_auxiliary(ordinal)),
6985                    auxiliary.stored_size,
6986                    Some(auxiliary.sha256),
6987                ));
6988            } else {
6989                sections.push(StreamingMemberSection::payload(
6990                    Box::new(Cursor::new(record.payload.clone())),
6991                    auxiliary.stored_size,
6992                    Some(auxiliary.sha256),
6993                ));
6994            }
6995            if record.stored_payload_size() != auxiliary.stored_size {
6996                return Err(FormatError::WriterInvariant(
6997                    "auxiliary source declaration changed while opening",
6998                )
6999                .into());
7000            }
7001        }
7002        sections.push(StreamingMemberSection::bytes(layout.primary));
7003        sections.push(StreamingMemberSection::deferred_payload(
7004            Box::new(move || source.open()),
7005            primary_size,
7006            None,
7007        ));
7008        Ok(Self {
7009            sections,
7010            section_index: 0,
7011            pushback: Vec::new(),
7012        })
7013    }
7014
7015    fn push_back(&mut self, bytes: Vec<u8>) {
7016        if bytes.is_empty() {
7017            return;
7018        }
7019        if self.pushback.is_empty() {
7020            self.pushback = bytes;
7021        } else {
7022            let mut merged = bytes;
7023            merged.extend_from_slice(&self.pushback);
7024            self.pushback = merged;
7025        }
7026    }
7027}
7028
7029impl Read for StreamingMemberReader<'_> {
7030    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
7031        if out.is_empty() {
7032            return Ok(0);
7033        }
7034
7035        let mut written = 0usize;
7036        if !self.pushback.is_empty() {
7037            let count = out.len().min(self.pushback.len());
7038            out[..count].copy_from_slice(&self.pushback[..count]);
7039            self.pushback.drain(..count);
7040            written += count;
7041            if written == out.len() {
7042                return Ok(written);
7043            }
7044        }
7045
7046        while written < out.len() && self.section_index < self.sections.len() {
7047            let count = self.sections[self.section_index].read(&mut out[written..])?;
7048            if count == 0 {
7049                self.section_index += 1;
7050            } else {
7051                written += count;
7052            }
7053        }
7054
7055        Ok(written)
7056    }
7057}
7058
7059#[cfg(test)]
7060fn build_regular_file_member_prefix(
7061    path: &[u8],
7062    file_size: u64,
7063    mode: u32,
7064    mtime: ArchiveTimestamp,
7065    portable_metadata: &PortableFileMetadata,
7066) -> Result<Vec<u8>, FormatError> {
7067    build_primary_member_prefix(
7068        path,
7069        SourceEntryKind::Regular,
7070        None,
7071        file_size,
7072        None,
7073        mode,
7074        mtime,
7075        portable_metadata,
7076    )
7077}
7078
7079#[allow(clippy::too_many_arguments)]
7080fn build_primary_member_prefix(
7081    path: &[u8],
7082    entry_kind: SourceEntryKind,
7083    link_target: Option<&[u8]>,
7084    file_size: u64,
7085    sparse_extents: Option<&[SparseExtent]>,
7086    mode: u32,
7087    mtime: ArchiveTimestamp,
7088    portable_metadata: &PortableFileMetadata,
7089) -> Result<Vec<u8>, FormatError> {
7090    let layout = build_primary_member_layout(
7091        path,
7092        entry_kind,
7093        link_target,
7094        file_size,
7095        sparse_extents,
7096        mode,
7097        mtime,
7098        portable_metadata,
7099    )?;
7100    let mut out = Vec::new();
7101    for (ordinal, auxiliary) in layout.auxiliary.iter().enumerate() {
7102        let record = &portable_metadata.native.auxiliary_records[ordinal];
7103        if record.is_streamed() {
7104            return Err(FormatError::WriterUnsupported(
7105                "this writer path does not accept streamed auxiliary payloads",
7106            ));
7107        }
7108        out.extend_from_slice(&auxiliary.bytes);
7109        out.extend_from_slice(&record.payload);
7110        out.resize(
7111            out.len() + padding_to_512_u64(auxiliary.stored_size) as usize,
7112            0,
7113        );
7114    }
7115    out.extend_from_slice(&layout.primary);
7116    Ok(out)
7117}
7118
7119struct PrimaryMemberLayout {
7120    auxiliary: Vec<NativeAuxiliaryMemberPrefix>,
7121    primary: Vec<u8>,
7122}
7123
7124fn primary_member_layout_size(
7125    layout: &PrimaryMemberLayout,
7126    primary_payload_size: u64,
7127) -> Result<u64, FormatError> {
7128    let mut size = 0u64;
7129    for auxiliary in &layout.auxiliary {
7130        size = checked_u64_add(size, auxiliary.bytes.len() as u64, "auxiliary member")?;
7131        size = checked_u64_add(size, auxiliary.stored_size, "auxiliary member")?;
7132        size = checked_u64_add(
7133            size,
7134            padding_to_512_u64(auxiliary.stored_size),
7135            "auxiliary member",
7136        )?;
7137    }
7138    size = checked_u64_add(size, layout.primary.len() as u64, "primary member")?;
7139    size = checked_u64_add(size, primary_payload_size, "primary member")?;
7140    checked_u64_add(
7141        size,
7142        padding_to_512_u64(primary_payload_size),
7143        "primary member",
7144    )
7145}
7146
7147#[allow(clippy::too_many_arguments)]
7148fn build_primary_member_layout(
7149    path: &[u8],
7150    entry_kind: SourceEntryKind,
7151    link_target: Option<&[u8]>,
7152    file_size: u64,
7153    sparse_extents: Option<&[SparseExtent]>,
7154    mode: u32,
7155    mtime: ArchiveTimestamp,
7156    portable_metadata: &PortableFileMetadata,
7157) -> Result<PrimaryMemberLayout, FormatError> {
7158    if entry_kind != SourceEntryKind::Regular && (file_size != 0 || sparse_extents.is_some()) {
7159        return Err(FormatError::WriterInvariant(
7160            "non-regular member has non-zero file data size",
7161        ));
7162    }
7163    match (entry_kind, link_target) {
7164        (SourceEntryKind::Symlink, Some(target)) => {
7165            crate::tar_model::validate_symlink_target(path, target)?;
7166        }
7167        (SourceEntryKind::Hardlink, Some(target)) => {
7168            validate_file_path_bytes(target, u32::MAX)?;
7169            if target == path {
7170                return Err(FormatError::WriterInvariant("hardlink aliases itself"));
7171            }
7172            if portable_metadata.native != NativeFileMetadata::default() {
7173                return Err(FormatError::WriterInvariant(
7174                    "hardlink alias carries native file-object metadata",
7175                ));
7176            }
7177        }
7178        (SourceEntryKind::Symlink, None) => {
7179            return Err(FormatError::WriterInvariant(
7180                "symlink member has no link target",
7181            ));
7182        }
7183        (_, Some(_)) => {
7184            return Err(FormatError::WriterInvariant(
7185                "non-link member has a link target",
7186            ));
7187        }
7188        (_, None) => {}
7189    }
7190    let sparse_map = sparse_extents
7191        .map(|extents| encode_v45_sparse_map(extents, file_size))
7192        .transpose()?;
7193    let stored_extent_bytes = sparse_extents
7194        .map(|extents| sparse_extent_bytes(extents, file_size))
7195        .transpose()?
7196        .unwrap_or(file_size);
7197    let stored_size = checked_u64_add(
7198        sparse_map.as_ref().map_or(0, |map| map.len() as u64),
7199        stored_extent_bytes,
7200        "sparse primary stored size",
7201    )?;
7202    let use_path_override = sparse_map.is_none() && path_requires_pax(path);
7203    validate_portable_file_metadata(portable_metadata)?;
7204    let reparse_placeholder = portable_metadata
7205        .native
7206        .primary_pax_records
7207        .contains_key("TZAP.windows.reparse-placeholder");
7208    if matches!(
7209        entry_kind,
7210        SourceEntryKind::ReparseDirectory | SourceEntryKind::ReparseRegular
7211    ) != reparse_placeholder
7212    {
7213        return Err(FormatError::WriterInvariant(
7214            "Windows reparse source kind and placeholder metadata disagree",
7215        ));
7216    }
7217    let mut pax_records =
7218        portable_primary_pax(path, mode, &portable_metadata.source_os, use_path_override)?;
7219    pax_records.insert(
7220        "TZAP.metadata.source-filesystem".into(),
7221        portable_metadata.source_filesystem.as_bytes().to_vec(),
7222    );
7223    pax_records.insert(
7224        "TZAP.portable.mode-origin".into(),
7225        match portable_metadata.mode_origin {
7226            PortableModeOrigin::Native => b"native".to_vec(),
7227            PortableModeOrigin::Projected => b"projected".to_vec(),
7228        },
7229    );
7230    if let Some(attributes) = portable_metadata.attributes {
7231        pax_records.insert(
7232            "TZAP.portable.attributes".into(),
7233            hex::encode(attributes.to_be_bytes()).into_bytes(),
7234        );
7235    }
7236    if sparse_map.is_some() {
7237        pax_records.insert("GNU.sparse.major".into(), b"1".to_vec());
7238        pax_records.insert("GNU.sparse.minor".into(), b"0".to_vec());
7239        pax_records.insert("GNU.sparse.name".into(), path.to_vec());
7240        pax_records.insert(
7241            "GNU.sparse.realsize".into(),
7242            file_size.to_string().into_bytes(),
7243        );
7244    }
7245    merge_native_primary_metadata(&mut pax_records, &portable_metadata.native)?;
7246    if portable_metadata
7247        .native
7248        .auxiliary_records
7249        .iter()
7250        .any(|record| record.kind == CAPTURE_REPORT_KIND)
7251    {
7252        pax_records.insert("TZAP.metadata.capture-status".into(), b"partial".to_vec());
7253    }
7254    let use_linkpath_override = link_target.is_some_and(|target| target.len() > 100);
7255    if use_linkpath_override {
7256        pax_records.insert(
7257            "linkpath".into(),
7258            link_target.expect("link target was checked").to_vec(),
7259        );
7260    }
7261    let primary_identity = prepare_primary_tar_identity(&mut pax_records, portable_metadata)?;
7262    let header_mtime = if mtime.nanoseconds == 0 && mtime.seconds >= 0 {
7263        let seconds = mtime.seconds as u64;
7264        if tar_octal_fits(12, seconds) {
7265            seconds
7266        } else {
7267            pax_records.insert("mtime".into(), mtime.canonical_pax_value()?);
7268            0
7269        }
7270    } else {
7271        pax_records.insert("mtime".into(), mtime.canonical_pax_value()?);
7272        0
7273    };
7274    let header_size = if tar_octal_fits(12, stored_size) {
7275        stored_size
7276    } else {
7277        pax_records.insert("size".into(), stored_size.to_string().into_bytes());
7278        0
7279    };
7280    let primary_metadata = parse_primary_metadata(&pax_records).map_err(|_| {
7281        FormatError::WriterUnsupported("native primary metadata is not a valid v45 declaration")
7282    })?;
7283    let mut parsed_auxiliary = Vec::with_capacity(portable_metadata.native.auxiliary_records.len());
7284    let mut auxiliary = Vec::with_capacity(portable_metadata.native.auxiliary_records.len());
7285    for (ordinal, record) in portable_metadata
7286        .native
7287        .auxiliary_records
7288        .iter()
7289        .enumerate()
7290    {
7291        let ordinal = u32::try_from(ordinal).map_err(|_| {
7292            FormatError::WriterUnsupported("auxiliary record count exceeds revision-45 range")
7293        })?;
7294        let member = build_native_auxiliary_member_prefix(ordinal, record)?;
7295        parsed_auxiliary.push(member.parsed.clone());
7296        auxiliary.push(member);
7297    }
7298    validate_group_metadata(&primary_metadata, &parsed_auxiliary).map_err(|_| {
7299        FormatError::WriterUnsupported("native auxiliary metadata is not a valid v45 declaration")
7300    })?;
7301    let pax_payload = encode_canonical_pax(&pax_records)?;
7302    let pax_header = build_ustar_header(b"TZAP-PAX/PRIMARY", pax_payload.len() as u64, 0, 0, b'x')?;
7303    let mut primary = Vec::new();
7304    primary.extend_from_slice(&pax_header);
7305    primary.extend_from_slice(&pax_payload);
7306    primary.resize(primary.len() + padding_to_512(pax_payload.len()), 0);
7307
7308    let header_path = if sparse_map.is_some() {
7309        b"GNUSparseFile.0/TZAP".to_vec()
7310    } else if use_path_override {
7311        b"TZAP-PRIMARY".to_vec()
7312    } else {
7313        path.to_vec()
7314    };
7315
7316    let typeflag = match entry_kind {
7317        SourceEntryKind::Regular | SourceEntryKind::ReparseRegular => b'0',
7318        SourceEntryKind::Directory | SourceEntryKind::ReparseDirectory => b'5',
7319        SourceEntryKind::Symlink => b'2',
7320        SourceEntryKind::Hardlink => b'1',
7321        SourceEntryKind::CharacterDevice => b'3',
7322        SourceEntryKind::BlockDevice => b'4',
7323        SourceEntryKind::Fifo => b'6',
7324    };
7325    let mut header = build_ustar_header(&header_path, header_size, mode, header_mtime, typeflag)?;
7326    if let Some(target) = link_target.filter(|_| !use_linkpath_override) {
7327        header[157..157 + target.len()].copy_from_slice(target);
7328        finalize_tar_checksum(&mut header)?;
7329    }
7330    apply_primary_tar_identity(&mut header, &primary_identity)?;
7331    if matches!(
7332        entry_kind,
7333        SourceEntryKind::CharacterDevice | SourceEntryKind::BlockDevice
7334    ) {
7335        let parse_device = |key: &str| -> Result<u64, FormatError> {
7336            let value = pax_records.get(key).ok_or(FormatError::WriterInvariant(
7337                "device member lacks device-number metadata",
7338            ))?;
7339            let text = std::str::from_utf8(value).map_err(|_| {
7340                FormatError::WriterUnsupported("device number is not canonical decimal")
7341            })?;
7342            text.parse::<u64>().map_err(|_| {
7343                FormatError::WriterUnsupported("device number is not canonical decimal")
7344            })
7345        };
7346        let major = parse_device("TZAP.posix.device-major")?;
7347        let minor = parse_device("TZAP.posix.device-minor")?;
7348        if tar_octal_fits(8, major) {
7349            write_tar_octal(&mut header[329..337], major)?;
7350        }
7351        if tar_octal_fits(8, minor) {
7352            write_tar_octal(&mut header[337..345], minor)?;
7353        }
7354        finalize_tar_checksum(&mut header)?;
7355    }
7356    primary.extend_from_slice(&header);
7357    if let Some(sparse_map) = sparse_map {
7358        primary.extend_from_slice(&sparse_map);
7359    }
7360    Ok(PrimaryMemberLayout { auxiliary, primary })
7361}
7362
7363fn sparse_extent_bytes(extents: &[SparseExtent], logical_size: u64) -> Result<u64, FormatError> {
7364    if extents.len() > MAX_SPARSE_EXTENTS {
7365        return Err(FormatError::WriterUnsupported(
7366            "sparse extent count exceeds revision-45 limit",
7367        ));
7368    }
7369    let mut previous_end = 0u64;
7370    let mut stored_size = 0u64;
7371    for (index, extent) in extents.iter().enumerate() {
7372        if extent.length == 0 || extent.offset < previous_end {
7373            return Err(FormatError::WriterUnsupported(
7374                "sparse extents overlap or have zero length",
7375            ));
7376        }
7377        if index != 0 && extent.offset == previous_end {
7378            return Err(FormatError::WriterUnsupported(
7379                "adjacent sparse extents must be merged",
7380            ));
7381        }
7382        let end = extent
7383            .offset
7384            .checked_add(extent.length)
7385            .ok_or(FormatError::WriterUnsupported("sparse extent overflow"))?;
7386        if end > logical_size {
7387            return Err(FormatError::WriterUnsupported(
7388                "sparse extent exceeds logical size",
7389            ));
7390        }
7391        stored_size =
7392            stored_size
7393                .checked_add(extent.length)
7394                .ok_or(FormatError::WriterUnsupported(
7395                    "sparse stored size overflow",
7396                ))?;
7397        previous_end = end;
7398    }
7399    Ok(stored_size)
7400}
7401
7402pub fn encode_v45_sparse_map(
7403    extents: &[SparseExtent],
7404    logical_size: u64,
7405) -> Result<Vec<u8>, FormatError> {
7406    sparse_extent_bytes(extents, logical_size)?;
7407    let mut map = Vec::new();
7408    map.extend_from_slice(extents.len().to_string().as_bytes());
7409    map.push(b'\n');
7410    for extent in extents {
7411        map.extend_from_slice(extent.offset.to_string().as_bytes());
7412        map.push(b'\n');
7413        map.extend_from_slice(extent.length.to_string().as_bytes());
7414        map.push(b'\n');
7415    }
7416    let padding = padding_to_512(map.len());
7417    map.resize(
7418        map.len()
7419            .checked_add(padding)
7420            .ok_or(FormatError::WriterUnsupported("sparse map size overflow"))?,
7421        0,
7422    );
7423    Ok(map)
7424}
7425
7426struct NativeAuxiliaryMemberPrefix {
7427    bytes: Vec<u8>,
7428    stored_size: u64,
7429    sha256: [u8; 32],
7430    parsed: crate::entry_metadata::AuxiliaryRecord,
7431}
7432
7433fn build_native_auxiliary_member_prefix(
7434    ordinal: u32,
7435    record: &NativeAuxiliaryMetadata,
7436) -> Result<NativeAuxiliaryMemberPrefix, FormatError> {
7437    if record.is_streamed()
7438        && !matches!(
7439            record.kind.as_str(),
7440            "windows.alternate-data"
7441                | "windows.property-data"
7442                | "windows.efs-raw"
7443                | "macos.resource-fork"
7444                | "generic.xattr"
7445        )
7446        && !record.kind.starts_with("x.")
7447    {
7448        return Err(FormatError::WriterUnsupported(
7449            "this auxiliary kind requires inline structural payload validation",
7450        ));
7451    }
7452    let mut pax_records = BTreeMap::new();
7453    pax_records.insert("TZAP.aux.version".into(), b"1".to_vec());
7454    pax_records.insert("TZAP.aux.kind".into(), record.kind.as_bytes().to_vec());
7455    pax_records.insert(
7456        "TZAP.aux.profile".into(),
7457        record.profile.as_bytes().to_vec(),
7458    );
7459    pax_records.insert(
7460        "TZAP.aux.restore-class".into(),
7461        match record.restore_class {
7462            RestoreClass::None => b"none".to_vec(),
7463            RestoreClass::Portable => b"portable".to_vec(),
7464            RestoreClass::SameOs => b"same-os".to_vec(),
7465            RestoreClass::System => b"system".to_vec(),
7466        },
7467    );
7468    pax_records.insert(
7469        "TZAP.aux.native".into(),
7470        if record.native { b"1" } else { b"0" }.to_vec(),
7471    );
7472    let (name_encoding, encoded_name) = match record.name_encoding {
7473        NativeAuxiliaryNameEncoding::None => ("none", record.name.clone()),
7474        NativeAuxiliaryNameEncoding::Utf8 => ("utf8", record.name.clone()),
7475        NativeAuxiliaryNameEncoding::Utf16Le => {
7476            ("utf16le-base64", canonical_base64_encode(&record.name))
7477        }
7478        NativeAuxiliaryNameEncoding::Bytes => {
7479            ("bytes-base64", canonical_base64_encode(&record.name))
7480        }
7481    };
7482    pax_records.insert(
7483        "TZAP.aux.name-encoding".into(),
7484        name_encoding.as_bytes().to_vec(),
7485    );
7486    pax_records.insert("TZAP.aux.name".into(), encoded_name);
7487    pax_records.insert(
7488        "TZAP.aux.flags".into(),
7489        hex::encode(record.flags.to_be_bytes()).into_bytes(),
7490    );
7491    pax_records.insert(
7492        "TZAP.aux.logical-size".into(),
7493        record.logical_size.to_string().into_bytes(),
7494    );
7495    let digest = record.sha256();
7496    pax_records.insert("TZAP.aux.sha256".into(), hex::encode(digest).into_bytes());
7497    for (key, value) in &record.meta {
7498        if pax_records.insert(key.clone(), value.clone()).is_some() {
7499            return Err(FormatError::WriterUnsupported(
7500                "auxiliary metadata collides with a writer-owned key",
7501            ));
7502        }
7503    }
7504
7505    let stored_size = record.stored_payload_size();
7506    let header_size = if tar_octal_fits(12, stored_size) {
7507        stored_size
7508    } else {
7509        pax_records.insert("size".into(), stored_size.to_string().into_bytes());
7510        0
7511    };
7512    let parsed = if record.is_streamed() {
7513        parse_auxiliary_declaration_for_writer(&pax_records, ordinal, stored_size)
7514    } else {
7515        parse_auxiliary_record(&pax_records, ordinal, stored_size, &record.payload)
7516    }?;
7517    let pax_payload = encode_canonical_pax(&pax_records)?;
7518    let pax_label = format!("TZAP-PAX/AUX/{ordinal:08x}");
7519    let pax_header =
7520        build_ustar_header(pax_label.as_bytes(), pax_payload.len() as u64, 0, 0, b'x')?;
7521    let auxiliary_label = format!("TZAP-AUX/{ordinal:08x}");
7522    let auxiliary_header = build_ustar_header(auxiliary_label.as_bytes(), header_size, 0, 0, b'Z')?;
7523    let mut bytes = Vec::new();
7524    bytes.extend_from_slice(&pax_header);
7525    bytes.extend_from_slice(&pax_payload);
7526    bytes.resize(bytes.len() + padding_to_512(pax_payload.len()), 0);
7527    bytes.extend_from_slice(&auxiliary_header);
7528    Ok(NativeAuxiliaryMemberPrefix {
7529        bytes,
7530        stored_size,
7531        sha256: digest,
7532        parsed,
7533    })
7534}
7535
7536fn merge_native_primary_metadata(
7537    pax_records: &mut crate::entry_metadata::PaxRecords,
7538    native: &NativeFileMetadata,
7539) -> Result<(), FormatError> {
7540    if native.required_profiles.is_empty()
7541        && native.optional_profiles.is_empty()
7542        && native.primary_pax_records.is_empty()
7543        && native.auxiliary_records.is_empty()
7544    {
7545        return Ok(());
7546    }
7547    let mut required = native.required_profiles.clone();
7548    required.push("portable-v1".into());
7549    required.sort();
7550    required.dedup();
7551    let mut optional = native.optional_profiles.clone();
7552    optional.sort();
7553    optional.dedup();
7554    if required
7555        .iter()
7556        .any(|profile| optional.binary_search(profile).is_ok())
7557    {
7558        return Err(FormatError::WriterUnsupported(
7559            "metadata profile is both required and optional",
7560        ));
7561    }
7562    pax_records.insert(
7563        "TZAP.metadata.required-profiles".into(),
7564        required.join(",").into_bytes(),
7565    );
7566    pax_records.insert(
7567        "TZAP.metadata.optional-profiles".into(),
7568        optional.join(",").into_bytes(),
7569    );
7570    for (key, value) in &native.primary_pax_records {
7571        if pax_records.contains_key(key)
7572            || matches!(
7573                key.as_str(),
7574                "path" | "linkpath" | "size" | "uid" | "gid" | "uname" | "gname" | "mtime"
7575            )
7576            || key.starts_with("TZAP.metadata.")
7577            || key.starts_with("TZAP.portable.")
7578        {
7579            return Err(FormatError::WriterUnsupported(
7580                "native metadata collides with a writer-owned primary key",
7581            ));
7582        }
7583        pax_records.insert(key.clone(), value.clone());
7584    }
7585    Ok(())
7586}
7587
7588#[derive(Debug, Default)]
7589struct PrimaryTarIdentity {
7590    uid: u64,
7591    gid: u64,
7592    uname: Vec<u8>,
7593    gname: Vec<u8>,
7594}
7595
7596fn validate_portable_file_metadata(metadata: &PortableFileMetadata) -> Result<(), FormatError> {
7597    if !is_source_os(&metadata.source_os) {
7598        return Err(FormatError::WriterUnsupported("invalid metadata source OS"));
7599    }
7600    if !valid_filesystem_token(&metadata.source_filesystem) {
7601        return Err(FormatError::WriterUnsupported(
7602            "invalid metadata source filesystem",
7603        ));
7604    }
7605    if metadata
7606        .attributes
7607        .is_some_and(|attributes| attributes & !0x0f != 0)
7608    {
7609        return Err(FormatError::WriterUnsupported(
7610            "portable attributes contain reserved bits",
7611        ));
7612    }
7613    Ok(())
7614}
7615
7616fn prepare_primary_tar_identity(
7617    pax_records: &mut crate::entry_metadata::PaxRecords,
7618    metadata: &PortableFileMetadata,
7619) -> Result<PrimaryTarIdentity, FormatError> {
7620    let Some(owner) = &metadata.posix_owner else {
7621        return Ok(PrimaryTarIdentity::default());
7622    };
7623    pax_records.insert("TZAP.portable.owner-kind".into(), b"posix".to_vec());
7624    let uid = if tar_octal_fits(8, owner.uid) {
7625        owner.uid
7626    } else {
7627        pax_records.insert("uid".into(), owner.uid.to_string().into_bytes());
7628        0
7629    };
7630    let gid = if tar_octal_fits(8, owner.gid) {
7631        owner.gid
7632    } else {
7633        pax_records.insert("gid".into(), owner.gid.to_string().into_bytes());
7634        0
7635    };
7636    let uname = prepare_primary_name(pax_records, "uname", owner.uname.as_deref())?;
7637    let gname = prepare_primary_name(pax_records, "gname", owner.gname.as_deref())?;
7638    Ok(PrimaryTarIdentity {
7639        uid,
7640        gid,
7641        uname,
7642        gname,
7643    })
7644}
7645
7646fn prepare_primary_name(
7647    pax_records: &mut crate::entry_metadata::PaxRecords,
7648    key: &'static str,
7649    name: Option<&str>,
7650) -> Result<Vec<u8>, FormatError> {
7651    let Some(name) = name else {
7652        return Ok(Vec::new());
7653    };
7654    if name.is_empty() || name.contains('\0') || name.nfc().collect::<String>() != name {
7655        return Err(FormatError::WriterUnsupported(
7656            "portable owner name is not canonical NFC UTF-8",
7657        ));
7658    }
7659    if name.len() <= 32 {
7660        Ok(name.as_bytes().to_vec())
7661    } else {
7662        pax_records.insert(key.into(), name.as_bytes().to_vec());
7663        Ok(Vec::new())
7664    }
7665}
7666
7667fn apply_primary_tar_identity(
7668    header: &mut [u8; TAR_BLOCK_LEN],
7669    identity: &PrimaryTarIdentity,
7670) -> Result<(), FormatError> {
7671    write_tar_octal(&mut header[108..116], identity.uid)?;
7672    write_tar_octal(&mut header[116..124], identity.gid)?;
7673    header[265..297].fill(0);
7674    header[265..265 + identity.uname.len()].copy_from_slice(&identity.uname);
7675    header[297..329].fill(0);
7676    header[297..297 + identity.gname.len()].copy_from_slice(&identity.gname);
7677    finalize_tar_checksum(header)
7678}
7679
7680#[cfg(test)]
7681fn build_regular_file_member_group(
7682    path: &[u8],
7683    contents: &[u8],
7684    mode: u32,
7685    mtime: ArchiveTimestamp,
7686    portable_metadata: &PortableFileMetadata,
7687) -> Result<Vec<u8>, FormatError> {
7688    let mut out = build_regular_file_member_prefix(
7689        path,
7690        contents.len() as u64,
7691        mode,
7692        mtime,
7693        portable_metadata,
7694    )?;
7695    out.extend_from_slice(contents);
7696    out.resize(out.len() + padding_to_512(contents.len()), 0);
7697    Ok(out)
7698}
7699
7700fn path_requires_pax(path: &[u8]) -> bool {
7701    path.len() > 100
7702}
7703
7704fn v45_portable_file_entry_flags(
7705    mode: u32,
7706    primary_sparse: bool,
7707    metadata: &PortableFileMetadata,
7708) -> u32 {
7709    EXTENDED_METADATA_V1
7710        | if metadata
7711            .native
7712            .auxiliary_records
7713            .iter()
7714            .any(|record| record.kind == CAPTURE_REPORT_KIND)
7715        {
7716            CAPTURE_PARTIAL
7717        } else {
7718            0
7719        }
7720        | if metadata.native.auxiliary_records.is_empty() {
7721            0
7722        } else {
7723            HAS_AUXILIARY_STREAMS
7724        }
7725        | if metadata
7726            .native
7727            .required_profiles
7728            .iter()
7729            .chain(&metadata.native.optional_profiles)
7730            .any(|profile| profile != PORTABLE_PROFILE)
7731            || !metadata.native.primary_pax_records.is_empty()
7732            || metadata
7733                .native
7734                .auxiliary_records
7735                .iter()
7736                .any(|record| record.native)
7737        {
7738            HAS_NATIVE_METADATA
7739        } else {
7740            0
7741        }
7742        | if primary_sparse
7743            || metadata
7744                .native
7745                .auxiliary_records
7746                .iter()
7747                .any(|record| record.flags & 1 != 0)
7748        {
7749            HAS_SPARSE_EXTENTS
7750        } else {
7751            0
7752        }
7753        | if mode & 0o6000 != 0
7754            || metadata.posix_owner.is_some()
7755            || native_metadata_requires_system_restore(&metadata.native, &metadata.source_os)
7756            || metadata
7757                .native
7758                .auxiliary_records
7759                .iter()
7760                .any(|record| record.restore_class == RestoreClass::System)
7761        {
7762            REQUIRES_SYSTEM_RESTORE
7763        } else {
7764            0
7765        }
7766}
7767
7768fn native_metadata_requires_system_restore(native: &NativeFileMetadata, source_os: &str) -> bool {
7769    native.primary_pax_records.iter().any(|(key, value)| {
7770        key.starts_with("TZAP.posix.device-")
7771            || key == "TZAP.linux.whiteout"
7772            || key == "TZAP.linux.project-id"
7773            || key == "TZAP.windows.reparse-placeholder"
7774            || key == "TZAP.windows.directory-case-sensitive"
7775            || key.starts_with("LIBARCHIVE.xattr.security")
7776            || key.starts_with("LIBARCHIVE.xattr.trusted")
7777            || key.starts_with("LIBARCHIVE.xattr.system")
7778            || (source_os == "linux"
7779                && key.starts_with("LIBARCHIVE.xattr.")
7780                && !key.starts_with("LIBARCHIVE.xattr.user.")
7781                && !key.starts_with("LIBARCHIVE.xattr.com.apple."))
7782            || (key == "TZAP.linux.fsflags"
7783                && std::str::from_utf8(value)
7784                    .ok()
7785                    .and_then(|value| u64::from_str_radix(value, 16).ok())
7786                    .is_some_and(|flags| flags & 0x30 != 0))
7787            || (key == "TZAP.bsd.st-flags"
7788                && std::str::from_utf8(value)
7789                    .ok()
7790                    .and_then(|value| u64::from_str_radix(value, 16).ok())
7791                    .is_some_and(|flags| flags & 0x0006_0006 != 0))
7792            || (key == "TZAP.macos.st-flags"
7793                && std::str::from_utf8(value)
7794                    .ok()
7795                    .and_then(|value| u64::from_str_radix(value, 16).ok())
7796                    .is_some_and(|flags| flags & 0x009f_0086 != 0))
7797            || (key == "TZAP.windows.data-stream-attributes"
7798                && std::str::from_utf8(value)
7799                    .ok()
7800                    .and_then(|value| u32::from_str_radix(value, 16).ok())
7801                    .is_some_and(|flags| flags & 0x0000_0002 != 0))
7802            || (key == "SCHILY.fflags"
7803                && std::str::from_utf8(value).ok().is_some_and(|value| {
7804                    value.split(',').any(|token| {
7805                        matches!(
7806                            token,
7807                            "append" | "immutable" | "sappnd" | "schg" | "uappnd" | "uchg"
7808                        )
7809                    })
7810                }))
7811    })
7812}
7813
7814fn build_ustar_header(
7815    path: &[u8],
7816    size: u64,
7817    mode: u32,
7818    mtime: u64,
7819    typeflag: u8,
7820) -> Result<[u8; TAR_BLOCK_LEN], FormatError> {
7821    if path.len() > 100 {
7822        return Err(FormatError::WriterUnsupported(
7823            "ustar path exceeds name field",
7824        ));
7825    }
7826    let mut header = [0u8; TAR_BLOCK_LEN];
7827    header[0..path.len()].copy_from_slice(path);
7828    write_tar_octal(&mut header[100..108], mode as u64)?;
7829    write_tar_octal(&mut header[108..116], 0)?;
7830    write_tar_octal(&mut header[116..124], 0)?;
7831    write_tar_octal(&mut header[124..136], size)?;
7832    write_tar_octal(&mut header[136..148], mtime)?;
7833    header[148..156].fill(b' ');
7834    header[156] = typeflag;
7835    header[257..263].copy_from_slice(b"ustar\0");
7836    header[263..265].copy_from_slice(b"00");
7837    finalize_tar_checksum(&mut header)?;
7838    Ok(header)
7839}
7840
7841fn finalize_tar_checksum(header: &mut [u8; TAR_BLOCK_LEN]) -> Result<(), FormatError> {
7842    header[148..156].fill(b' ');
7843    let checksum = header.iter().map(|byte| *byte as u32).sum::<u32>() as u64;
7844    write_tar_checksum(&mut header[148..156], checksum)
7845}
7846
7847fn write_tar_octal(field: &mut [u8], mut value: u64) -> Result<(), FormatError> {
7848    if field.is_empty() {
7849        return Err(FormatError::WriterUnsupported("tar octal field overflow"));
7850    }
7851    let mut i = field.len() - 1;
7852    field[i] = 0;
7853
7854    if value == 0 {
7855        if i == 0 {
7856            return Err(FormatError::WriterUnsupported("tar octal field overflow"));
7857        }
7858        i -= 1;
7859        field[i] = b'0';
7860    } else {
7861        while value > 0 {
7862            if i == 0 {
7863                return Err(FormatError::WriterUnsupported("tar octal field overflow"));
7864            }
7865            i -= 1;
7866            field[i] = b'0' + (value & 7) as u8;
7867            value >>= 3;
7868        }
7869    }
7870    while i > 0 {
7871        i -= 1;
7872        field[i] = b'0';
7873    }
7874    Ok(())
7875}
7876
7877fn tar_octal_fits(field_len: usize, mut value: u64) -> bool {
7878    if field_len == 0 {
7879        return false;
7880    }
7881    let max_digits = field_len - 1;
7882    let mut digits = 0;
7883    if value == 0 {
7884        digits = 1;
7885    }
7886    while value > 0 {
7887        digits += 1;
7888        value >>= 3;
7889    }
7890    digits <= max_digits
7891}
7892
7893fn write_tar_checksum(field: &mut [u8], mut value: u64) -> Result<(), FormatError> {
7894    if field.len() < 8 {
7895        return Err(FormatError::WriterUnsupported(
7896            "tar checksum field overflow",
7897        ));
7898    }
7899    let mut i = 6;
7900    while i > 0 {
7901        i -= 1;
7902        field[i] = b'0' + (value & 7) as u8;
7903        value >>= 3;
7904    }
7905    if value > 0 {
7906        return Err(FormatError::WriterUnsupported(
7907            "tar checksum field overflow",
7908        ));
7909    }
7910    field[6] = 0;
7911    field[7] = b' ';
7912    Ok(())
7913}
7914
7915fn member_frame_range(
7916    member_index: usize,
7917    frames: &[PayloadFrame],
7918) -> Result<(u64, u32), FormatError> {
7919    let first = frames
7920        .iter()
7921        .find(|frame| frame.member_index == member_index)
7922        .map(|frame| frame.frame_index)
7923        .ok_or(FormatError::WriterInvariant("member frame is missing"))?;
7924    let count = frames
7925        .iter()
7926        .filter(|frame| frame.member_index == member_index)
7927        .count();
7928    Ok((first, u32_len(count, "FileEntry.frame_count")?))
7929}
7930
7931fn envelope_frame_range(
7932    envelope_index: u64,
7933    frames: &[PayloadFrame],
7934) -> Result<(u64, u32), FormatError> {
7935    let first = frames
7936        .iter()
7937        .find(|frame| frame.envelope_index == envelope_index)
7938        .map(|frame| frame.frame_index)
7939        .ok_or(FormatError::WriterInvariant("envelope frame is missing"))?;
7940    let count = frames
7941        .iter()
7942        .filter(|frame| frame.envelope_index == envelope_index)
7943        .count();
7944    Ok((first, u32_len(count, "EnvelopeEntry.frame_count")?))
7945}
7946
7947fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
7948    let digest = Sha256::digest(bytes);
7949    let mut out = [0u8; 32];
7950    out.copy_from_slice(&digest);
7951    out
7952}
7953
7954fn padding_to_512(len: usize) -> usize {
7955    let remainder = len % TAR_BLOCK_LEN;
7956    if remainder == 0 {
7957        0
7958    } else {
7959        TAR_BLOCK_LEN - remainder
7960    }
7961}
7962
7963fn padding_to_512_u64(len: u64) -> u64 {
7964    let remainder = len % TAR_BLOCK_LEN as u64;
7965    if remainder == 0 {
7966        0
7967    } else {
7968        TAR_BLOCK_LEN as u64 - remainder
7969    }
7970}
7971
7972fn table_offset(len: usize, cursor: usize) -> Result<u32, FormatError> {
7973    if len == 0 {
7974        Ok(0)
7975    } else {
7976        u32_len(cursor, "table offset")
7977    }
7978}
7979
7980fn u32_len(value: usize, field: &'static str) -> Result<u32, FormatError> {
7981    u32::try_from(value).map_err(|_| FormatError::WriterUnsupported(field))
7982}
7983
7984fn to_usize_writer(value: u64, field: &'static str) -> Result<usize, FormatError> {
7985    usize::try_from(value).map_err(|_| FormatError::WriterUnsupported(field))
7986}
7987
7988fn checked_usize_add(lhs: usize, rhs: usize, field: &'static str) -> Result<usize, FormatError> {
7989    lhs.checked_add(rhs)
7990        .ok_or(FormatError::WriterUnsupported(field))
7991}
7992
7993fn checked_u64_add(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
7994    lhs.checked_add(rhs)
7995        .ok_or(FormatError::WriterUnsupported(field))
7996}
7997
7998#[cfg(test)]
7999mod tests {
8000    use super::*;
8001    use crate::crypto::{verify_hmac, Subkeys};
8002    use crate::metadata::{DirectoryHintTable, IndexShard, MetadataLimits};
8003    use crate::reader::{open_archive, open_archive_with_recipient_wrap_resolver};
8004    use crate::tar_model::parse_tar_member_group;
8005    use crate::wire::{CriticalRecoveryLocator, CryptoHeader};
8006    use std::cell::RefCell;
8007    use std::io::{self, Read};
8008    use std::rc::Rc;
8009
8010    #[test]
8011    fn writer_defaults_use_v41_sizing_and_parallel_mode() {
8012        let options = WriterOptions::default();
8013
8014        assert_eq!(options.chunk_size, 256 * 1024);
8015        assert_eq!(options.envelope_target_size, 1024 * 1024);
8016        assert_eq!(options.block_size, 64 * 1024);
8017        assert_eq!(options.stripe_width, 8);
8018        assert_eq!(options.volume_loss_tolerance, 1);
8019        assert_eq!(options.fec_data_shards, 224);
8020        assert_eq!(options.index_fec_data_shards, 16);
8021        assert_eq!(
8022            options.index_root_fec_data_shards,
8023            MIN_INDEX_ROOT_FEC_DATA_SHARDS
8024        );
8025        assert_eq!(options.bit_rot_buffer_pct, 5);
8026        assert_eq!(options.jobs, default_jobs());
8027        assert!(options.jobs >= 1);
8028    }
8029
8030    #[test]
8031    fn emission_state_collects_data_leaf_hashes_only_for_root_auth() {
8032        let options = single_volume_metadata_test_options();
8033        let archive_uuid = [1u8; 16];
8034        let session_id = [2u8; 16];
8035        let crypto_header = b"test crypto header";
8036
8037        let mut unsigned_sink = MemoryArchiveSink::default();
8038        let volume_format_rev = volume_format_revision_for_options(&options, &KdfParams::None);
8039        let unsigned = begin_writer_emission_state(
8040            &mut unsigned_sink,
8041            options,
8042            crypto_header,
8043            None,
8044            archive_uuid,
8045            session_id,
8046            volume_format_rev,
8047            false,
8048        )
8049        .unwrap();
8050        assert!(unsigned.data_leaf_hashes.is_none());
8051
8052        let mut signed_sink = MemoryArchiveSink::default();
8053        let volume_format_rev = volume_format_revision_for_options(&options, &KdfParams::None);
8054        let signed = begin_writer_emission_state(
8055            &mut signed_sink,
8056            options,
8057            crypto_header,
8058            None,
8059            archive_uuid,
8060            session_id,
8061            volume_format_rev,
8062            true,
8063        )
8064        .unwrap();
8065        assert_eq!(signed.data_leaf_hashes.as_deref(), Some([].as_slice()));
8066    }
8067
8068    #[test]
8069    fn ordered_envelope_serializes_zero_parity_without_root_auth_leaf_collection() {
8070        let options = plan_writer_options(WriterOptions {
8071            stripe_width: 1,
8072            volume_loss_tolerance: 0,
8073            bit_rot_buffer_pct: 0,
8074            aead_algo: AeadAlgo::None,
8075            ..WriterOptions::default()
8076        })
8077        .unwrap();
8078        let payload = deterministic_bytes(options.block_size as usize * 2 + 17);
8079        let extent = ObjectExtent::new(
8080            11,
8081            plan_encrypted_object(
8082                payload.len(),
8083                options.fec_data_shards,
8084                options.fec_parity_shards,
8085                options,
8086            )
8087            .unwrap(),
8088        )
8089        .unwrap();
8090        assert_eq!(extent.parity_block_count, 0);
8091        assert_eq!(extent.data_block_count, 3);
8092        let subkeys = Subkeys::unencrypted_placeholder();
8093
8094        let result = build_ordered_envelope_result(
8095            OrderedEnvelopeJob {
8096                envelope_index: 3,
8097                plaintext: payload.clone(),
8098                extent,
8099                collect_data_leaf_hashes: false,
8100            },
8101            &subkeys,
8102            options,
8103            [1u8; 16],
8104            [2u8; 16],
8105        )
8106        .unwrap();
8107
8108        match result.records {
8109            OrderedEnvelopeRecords::Serialized(records) => {
8110                assert_eq!(records.len(), extent.data_block_count as usize);
8111                let parsed =
8112                    BlockRecord::parse(&records[0].bytes, options.block_size as usize).unwrap();
8113                assert_eq!(parsed.block_index, extent.first_block_index);
8114                assert_eq!(parsed.kind, BlockKind::PayloadData);
8115                assert!(!parsed.is_last_data());
8116                let last =
8117                    BlockRecord::parse(&records[2].bytes, options.block_size as usize).unwrap();
8118                assert_eq!(last.block_index, extent.first_block_index + 2);
8119                assert_eq!(last.kind, BlockKind::PayloadData);
8120                assert!(last.is_last_data());
8121            }
8122            OrderedEnvelopeRecords::Materialized(_) => {
8123                panic!("zero-parity unsigned envelope should use serialized records")
8124            }
8125        }
8126
8127        let result = build_ordered_envelope_result(
8128            OrderedEnvelopeJob {
8129                envelope_index: 3,
8130                plaintext: payload,
8131                extent,
8132                collect_data_leaf_hashes: true,
8133            },
8134            &subkeys,
8135            options,
8136            [1u8; 16],
8137            [2u8; 16],
8138        )
8139        .unwrap();
8140
8141        match result.records {
8142            OrderedEnvelopeRecords::Materialized(records) => {
8143                assert_eq!(records.len(), extent.data_block_count as usize);
8144            }
8145            OrderedEnvelopeRecords::Serialized(_) => {
8146                panic!("root-auth leaf collection requires materialized records")
8147            }
8148        }
8149    }
8150
8151    #[test]
8152    fn ordered_envelope_serializes_encrypted_zero_parity_like_materialized_path() {
8153        let options = plan_writer_options(WriterOptions {
8154            stripe_width: 1,
8155            volume_loss_tolerance: 0,
8156            bit_rot_buffer_pct: 0,
8157            aead_algo: AeadAlgo::AesGcmSiv256,
8158            ..WriterOptions::default()
8159        })
8160        .unwrap();
8161        let payload = deterministic_bytes(options.block_size as usize * 2 + 17);
8162        let extent = ObjectExtent::new(
8163            29,
8164            plan_encrypted_object(
8165                payload.len(),
8166                options.fec_data_shards,
8167                options.fec_parity_shards,
8168                options,
8169            )
8170            .unwrap(),
8171        )
8172        .unwrap();
8173        assert_eq!(extent.parity_block_count, 0);
8174        assert_eq!(extent.data_block_count, 3);
8175        let archive_uuid = [3u8; 16];
8176        let session_id = [4u8; 16];
8177        let master_key = MasterKey::from_raw_key(&[0x5a; 32]).unwrap();
8178        let subkeys = Subkeys::derive(&master_key, &archive_uuid, &session_id).unwrap();
8179
8180        let serialized = build_ordered_envelope_result(
8181            OrderedEnvelopeJob {
8182                envelope_index: 7,
8183                plaintext: payload.clone(),
8184                extent,
8185                collect_data_leaf_hashes: false,
8186            },
8187            &subkeys,
8188            options,
8189            archive_uuid,
8190            session_id,
8191        )
8192        .unwrap();
8193        let serialized_bytes = match serialized.records {
8194            OrderedEnvelopeRecords::Serialized(records) => records
8195                .into_iter()
8196                .map(|record| record.bytes)
8197                .collect::<Vec<_>>(),
8198            OrderedEnvelopeRecords::Materialized(_) => {
8199                panic!("zero-parity encrypted envelope should use serialized records")
8200            }
8201        };
8202
8203        let materialized = build_ordered_envelope_result(
8204            OrderedEnvelopeJob {
8205                envelope_index: 7,
8206                plaintext: payload,
8207                extent,
8208                collect_data_leaf_hashes: true,
8209            },
8210            &subkeys,
8211            options,
8212            archive_uuid,
8213            session_id,
8214        )
8215        .unwrap();
8216        let materialized_bytes = match materialized.records {
8217            OrderedEnvelopeRecords::Materialized(records) => records
8218                .iter()
8219                .map(BlockRecord::to_bytes)
8220                .collect::<Vec<_>>(),
8221            OrderedEnvelopeRecords::Serialized(_) => {
8222                panic!("root-auth leaf collection requires materialized records")
8223            }
8224        };
8225
8226        assert_eq!(serialized_bytes, materialized_bytes);
8227    }
8228
8229    #[test]
8230    fn writer_options_reject_zero_jobs() {
8231        let err = plan_writer_options(WriterOptions {
8232            jobs: 0,
8233            ..WriterOptions::default()
8234        })
8235        .unwrap_err();
8236
8237        assert_eq!(
8238            err,
8239            FormatError::WriterUnsupported("jobs must be at least 1")
8240        );
8241    }
8242
8243    #[test]
8244    fn production_writer_defaults_generate_distinct_v4_identities() {
8245        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
8246        let first = write_archive(&[], &master_key, WriterOptions::default()).unwrap();
8247        let second = write_archive(&[], &master_key, WriterOptions::default()).unwrap();
8248
8249        assert_ne!(first.archive_uuid, [0u8; 16]);
8250        assert_ne!(first.session_id, [0u8; 16]);
8251        assert_ne!(second.archive_uuid, [0u8; 16]);
8252        assert_ne!(second.session_id, [0u8; 16]);
8253        assert_ne!(first.archive_uuid, first.session_id);
8254        assert_ne!(first.archive_uuid, second.archive_uuid);
8255        assert_ne!(first.session_id, second.session_id);
8256
8257        for raw in [
8258            first.archive_uuid,
8259            first.session_id,
8260            second.archive_uuid,
8261            second.session_id,
8262        ] {
8263            let id = Uuid::from_bytes(raw);
8264            assert_eq!(id.get_version_num(), 4);
8265        }
8266
8267        let deterministic = WriterOptions {
8268            archive_uuid: Some([0x44; 16]),
8269            session_id: Some([0x55; 16]),
8270            ..WriterOptions::default()
8271        };
8272        let fixture = write_archive(&[], &master_key, deterministic).unwrap();
8273        assert_eq!(fixture.archive_uuid, [0x44; 16]);
8274        assert_eq!(fixture.session_id, [0x55; 16]);
8275    }
8276
8277    #[test]
8278    fn writer_partitions_multiple_default_sized_index_shards() {
8279        let members = (0..=DEFAULT_FILES_PER_INDEX_SHARD)
8280            .map(|idx| TarMember {
8281                path: format!("file-{idx:05}.txt").into_bytes(),
8282                entry_kind: SourceEntryKind::Regular,
8283                link_target: None,
8284                tar_member_group_start: idx as u64 * 512,
8285                tar_member_group_size: 512,
8286                file_data_size: 0,
8287                sparse_extents: None,
8288                mode: 0o644,
8289                mtime: ArchiveTimestamp::UNIX_EPOCH,
8290                portable_metadata: PortableFileMetadata::default(),
8291            })
8292            .collect::<Vec<_>>();
8293
8294        let shards = partition_file_rows(sorted_file_rows(&members)).unwrap();
8295
8296        assert_eq!(shards.len(), 2);
8297        assert_eq!(shards[0].len(), DEFAULT_FILES_PER_INDEX_SHARD);
8298        assert_eq!(shards[1].len(), 1);
8299    }
8300
8301    #[test]
8302    fn writer_extends_shard_for_bounded_hash_prefix_run() {
8303        let mut rows = Vec::new();
8304        rows.extend((0..9_000).map(|idx| test_file_row(idx, [0u8; 8])));
8305        rows.extend((9_000..54_000).map(|idx| test_file_row(idx, [1u8; 8])));
8306        rows.push(test_file_row(54_000, [2u8; 8]));
8307
8308        let shards = partition_file_rows(rows).unwrap();
8309
8310        assert_eq!(shards.len(), 2);
8311        assert_eq!(shards[0].len(), 54_000);
8312        assert!(shards[0]
8313            .iter()
8314            .skip(9_000)
8315            .all(|row| row.path_hash == [1u8; 8]));
8316        assert_eq!(shards[1][0].path_hash, [2u8; 8]);
8317    }
8318
8319    #[test]
8320    fn writer_splits_oversized_hash_prefix_run_at_writer_ceiling() {
8321        let rows = (0..MAX_HASH_PREFIX_RUN_FILES + 1)
8322            .map(|idx| test_file_row(idx, [7u8; 8]))
8323            .collect::<Vec<_>>();
8324
8325        let shards = partition_file_rows(rows).unwrap();
8326
8327        assert_eq!(shards.len(), 2);
8328        assert_eq!(shards[0].len(), MAX_HASH_PREFIX_RUN_FILES);
8329        assert_eq!(shards[1].len(), 1);
8330    }
8331
8332    #[test]
8333    fn writer_builds_directory_hint_rows_for_ancestor_directories() {
8334        let shard_rows = vec![
8335            vec![FileRow {
8336                path_hash: hash_prefix(b"a/b/one.txt"),
8337                path: b"a/b/one.txt".to_vec(),
8338                member_index: 0,
8339                member: TarMember {
8340                    path: b"a/b/one.txt".to_vec(),
8341                    entry_kind: SourceEntryKind::Regular,
8342                    link_target: None,
8343                    tar_member_group_start: 0,
8344                    tar_member_group_size: 512,
8345                    file_data_size: 0,
8346                    sparse_extents: None,
8347                    mode: 0o644,
8348                    mtime: ArchiveTimestamp::UNIX_EPOCH,
8349                    portable_metadata: PortableFileMetadata::default(),
8350                },
8351            }],
8352            vec![FileRow {
8353                path_hash: hash_prefix(b"a/c/two.txt"),
8354                path: b"a/c/two.txt".to_vec(),
8355                member_index: 1,
8356                member: TarMember {
8357                    path: b"a/c/two.txt".to_vec(),
8358                    entry_kind: SourceEntryKind::Regular,
8359                    link_target: None,
8360                    tar_member_group_start: 512,
8361                    tar_member_group_size: 512,
8362                    file_data_size: 0,
8363                    sparse_extents: None,
8364                    mode: 0o644,
8365                    mtime: ArchiveTimestamp::UNIX_EPOCH,
8366                    portable_metadata: PortableFileMetadata::default(),
8367                },
8368            }],
8369        ];
8370
8371        let options = plan_writer_options(WriterOptions::default()).unwrap();
8372        let planned = build_directory_hint_plaintexts(&shard_rows, options).unwrap();
8373        assert_eq!(planned.len(), 1);
8374        let locating = DirectoryHintShardEntry {
8375            hint_shard_index: planned[0].hint_shard_index,
8376            first_dir_hash: planned[0].first_dir_hash,
8377            last_dir_hash: planned[0].last_dir_hash,
8378            first_block_index: 0,
8379            data_block_count: 1,
8380            parity_block_count: 0,
8381            encrypted_size: 4096,
8382            decompressed_size: planned[0].plaintext.len() as u32,
8383            entry_count: planned[0].entry_count,
8384        };
8385        let table = DirectoryHintTable::parse(
8386            &planned[0].plaintext,
8387            &locating,
8388            2,
8389            MetadataLimits::default(),
8390        )
8391        .unwrap();
8392
8393        let root = table.lookup_directory_index(b"").unwrap();
8394        assert_eq!(table.shard_rows_for_entry(root).unwrap(), &[0, 1]);
8395        let a = table.lookup_directory_index(b"a").unwrap();
8396        assert_eq!(table.shard_rows_for_entry(a).unwrap(), &[0, 1]);
8397        let ab = table.lookup_directory_index(b"a/b").unwrap();
8398        assert_eq!(table.shard_rows_for_entry(ab).unwrap(), &[0]);
8399    }
8400
8401    #[test]
8402    fn directory_hints_are_required_only_above_v41_threshold() {
8403        assert!(!should_emit_directory_hints(0));
8404        assert!(!should_emit_directory_hints(
8405            DIRECTORY_HINT_REQUIRED_FILE_COUNT
8406        ));
8407        assert!(should_emit_directory_hints(
8408            DIRECTORY_HINT_REQUIRED_FILE_COUNT + 1
8409        ));
8410    }
8411
8412    #[test]
8413    fn regular_file_writer_uses_local_pax_path_for_long_and_non_ascii_paths() {
8414        let long_path = format!("dir/{}.txt", "a".repeat(120));
8415        let unicode_path = "unicode/e\u{301}.txt";
8416        let files = [
8417            RegularFile::new(&long_path, b"long path"),
8418            RegularFile::new(unicode_path, b"unicode path"),
8419        ];
8420
8421        let (tar_stream, members) = build_tar_stream(&files, 4096).unwrap();
8422
8423        for (member, expected_path, expected_data) in [
8424            (&members[0], long_path.as_bytes(), b"long path".as_slice()),
8425            (
8426                &members[1],
8427                "unicode/\u{e9}.txt".as_bytes(),
8428                b"unicode path".as_slice(),
8429            ),
8430        ] {
8431            let start = member.tar_member_group_start as usize;
8432            let end = start + member.tar_member_group_size as usize;
8433            let group = &tar_stream[start..end];
8434            assert_eq!(group[156], b'x');
8435            let parsed = parse_tar_member_group(group, 4096).unwrap();
8436            assert_eq!(parsed.path, expected_path);
8437            assert_eq!(parsed.data, expected_data);
8438        }
8439    }
8440
8441    #[test]
8442    fn regular_file_writer_emits_no_global_metadata_or_tar_eof() {
8443        let long_path = format!("dir/{}.txt", "a".repeat(120));
8444        let files = [
8445            RegularFile::new("plain.txt", b"plain contents"),
8446            RegularFile::new(&long_path, b"long path contents"),
8447        ];
8448
8449        let (tar_stream, members) = build_tar_stream(&files, 4096).unwrap();
8450
8451        let member_bytes = members
8452            .iter()
8453            .map(|member| member.tar_member_group_size)
8454            .sum::<u64>();
8455        assert_eq!(tar_stream.len() as u64, member_bytes);
8456        assert!(!tar_stream[tar_stream.len() - TAR_BLOCK_LEN * 2..]
8457            .chunks(TAR_BLOCK_LEN)
8458            .all(|block| block.iter().all(|byte| *byte == 0)));
8459
8460        for member in members {
8461            let start = member.tar_member_group_start as usize;
8462            let end = start + member.tar_member_group_size as usize;
8463            assert_path_specific_member_group(&tar_stream[start..end]);
8464        }
8465    }
8466
8467    struct SparseTestSource {
8468        logical_size: u64,
8469        extents: Vec<SparseExtent>,
8470        extent_bytes: Vec<u8>,
8471    }
8472
8473    impl RegularFileSource for SparseTestSource {
8474        fn archive_path(&self) -> &str {
8475            "sparse.bin"
8476        }
8477
8478        fn file_data_size(&self) -> u64 {
8479            self.logical_size
8480        }
8481
8482        fn sparse_extents(&self) -> Option<&[SparseExtent]> {
8483            Some(&self.extents)
8484        }
8485
8486        fn mode(&self) -> u32 {
8487            0o644
8488        }
8489
8490        fn mtime(&self) -> ArchiveTimestamp {
8491            ArchiveTimestamp::UNIX_EPOCH
8492        }
8493
8494        fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
8495            Ok(Box::new(Cursor::new(self.extent_bytes.as_slice())))
8496        }
8497    }
8498
8499    #[test]
8500    fn sparse_writer_emits_canonical_gnu_sparse_primary_and_all_hole_file() {
8501        for source in [
8502            SparseTestSource {
8503                logical_size: 32,
8504                extents: vec![
8505                    SparseExtent {
8506                        offset: 4,
8507                        length: 3,
8508                    },
8509                    SparseExtent {
8510                        offset: 16,
8511                        length: 2,
8512                    },
8513                    SparseExtent {
8514                        offset: 30,
8515                        length: 2,
8516                    },
8517                ],
8518                extent_bytes: b"abcdeyz".to_vec(),
8519            },
8520            SparseTestSource {
8521                logical_size: 1 << 20,
8522                extents: Vec::new(),
8523                extent_bytes: Vec::new(),
8524            },
8525        ] {
8526            let expected_map_prefix = format!("{}\n", source.extents.len());
8527            let (tar_stream, members) = build_tar_stream(&[source], 4096).unwrap();
8528            let parsed = parse_tar_member_group(&tar_stream, 4096).unwrap();
8529            assert_eq!(parsed.path, b"sparse.bin");
8530            assert_eq!(parsed.logical_size, members[0].file_data_size);
8531            let layout = parsed.v45_metadata.sparse_layout.unwrap();
8532            assert_eq!(layout.logical_size, members[0].file_data_size);
8533            assert_eq!(layout.extents, members[0].sparse_extents.clone().unwrap());
8534            assert!(parsed.data.starts_with(expected_map_prefix.as_bytes()));
8535        }
8536    }
8537
8538    #[test]
8539    fn sparse_writer_rejects_noncanonical_extent_maps() {
8540        for extents in [
8541            vec![SparseExtent {
8542                offset: 0,
8543                length: 0,
8544            }],
8545            vec![
8546                SparseExtent {
8547                    offset: 0,
8548                    length: 2,
8549                },
8550                SparseExtent {
8551                    offset: 2,
8552                    length: 2,
8553                },
8554            ],
8555            vec![SparseExtent {
8556                offset: 9,
8557                length: 2,
8558            }],
8559        ] {
8560            let source = SparseTestSource {
8561                logical_size: 10,
8562                extents,
8563                extent_bytes: Vec::new(),
8564            };
8565            assert!(build_tar_stream(&[source], 4096).is_err());
8566        }
8567    }
8568
8569    #[test]
8570    fn sparse_writer_indexes_logical_size_and_streams_expanded_content() {
8571        let source = SparseTestSource {
8572            logical_size: 12,
8573            extents: vec![
8574                SparseExtent {
8575                    offset: 2,
8576                    length: 3,
8577                },
8578                SparseExtent {
8579                    offset: 9,
8580                    length: 2,
8581                },
8582            ],
8583            extent_bytes: b"abcxy".to_vec(),
8584        };
8585        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
8586        let mut sink = MemoryArchiveSink::default();
8587        write_archive_sources_to_sink(
8588            &[source],
8589            &master_key,
8590            single_volume_metadata_test_options(),
8591            None,
8592            &KdfParams::Raw,
8593            None,
8594            None,
8595            &mut sink,
8596        )
8597        .unwrap();
8598
8599        let opened = open_archive(&sink.volumes[0], &master_key).unwrap();
8600        opened.verify().unwrap();
8601        let entry = opened.lookup_index_entry("sparse.bin").unwrap().unwrap();
8602        assert_eq!(entry.file_data_size, 12);
8603        assert_ne!(entry.flags & HAS_SPARSE_EXTENTS, 0);
8604        assert_eq!(
8605            opened.extract_file("sparse.bin").unwrap().unwrap(),
8606            b"\0\0abc\0\0\0\0xy\0"
8607        );
8608    }
8609
8610    #[test]
8611    fn regular_file_writer_round_trips_mode_and_mtime() {
8612        let group = build_regular_file_member_group(
8613            b"script.sh",
8614            b"#!/bin/sh\n",
8615            0o755,
8616            ArchiveTimestamp::from_seconds(1_700_000_000),
8617            &PortableFileMetadata::default(),
8618        )
8619        .unwrap();
8620
8621        let parsed = parse_tar_member_group(&group, 4096).unwrap();
8622
8623        assert_eq!(parsed.mode, 0o755);
8624        assert_eq!(parsed.mtime, ArchiveTimestamp::from_seconds(1_700_000_000));
8625    }
8626
8627    #[test]
8628    fn regular_file_writer_round_trips_nanosecond_and_pre_epoch_mtimes() {
8629        for expected in [
8630            ArchiveTimestamp::new(1_700_000_000, 123_456_789),
8631            ArchiveTimestamp::new(-1, 500_000_000),
8632        ] {
8633            let group = build_regular_file_member_group(
8634                b"dated.txt",
8635                b"dated",
8636                0o644,
8637                expected,
8638                &PortableFileMetadata::default(),
8639            )
8640            .unwrap();
8641            let parsed = parse_tar_member_group(&group, 4096).unwrap();
8642            assert_eq!(parsed.mtime, expected);
8643            assert_eq!(
8644                parsed.v45_metadata.portable_mirror.mtime,
8645                (expected.seconds, expected.nanoseconds)
8646            );
8647        }
8648    }
8649
8650    #[test]
8651    fn regular_file_writer_rejects_invalid_timestamp_nanoseconds() {
8652        assert!(matches!(
8653            build_regular_file_member_group(
8654                b"dated.txt",
8655                b"dated",
8656                0o644,
8657                ArchiveTimestamp::new(0, 1_000_000_000),
8658                &PortableFileMetadata::default(),
8659            ),
8660            Err(FormatError::WriterUnsupported(
8661                "timestamp nanoseconds must be less than one billion"
8662            ))
8663        ));
8664    }
8665
8666    #[test]
8667    fn macos_entitlement_and_superuser_flags_require_system_restore() {
8668        for flags in [0x0000_0080u64, 0x0008_0000, 0x0010_0000, 0x0080_0000] {
8669            let mut native = NativeFileMetadata::default();
8670            native.primary_pax_records.insert(
8671                "TZAP.macos.st-flags".into(),
8672                format!("{flags:016x}").into_bytes(),
8673            );
8674            assert!(native_metadata_requires_system_restore(&native, "macos"));
8675        }
8676    }
8677
8678    #[test]
8679    fn linux_bsd_macos_immutable_flags_require_system_restore() {
8680        let mut native = NativeFileMetadata::default();
8681        native.primary_pax_records.insert(
8682            "TZAP.linux.fsflags".into(),
8683            "0000000000000030".into(), // append and immutable
8684        );
8685        assert!(native_metadata_requires_system_restore(&native, "linux"));
8686
8687        let mut native = NativeFileMetadata::default();
8688        native.primary_pax_records.insert(
8689            "TZAP.bsd.st-flags".into(),
8690            "0000000000060006".into(), // append and immutable
8691        );
8692        assert!(native_metadata_requires_system_restore(&native, "freebsd"));
8693
8694        let mut native = NativeFileMetadata::default();
8695        native.primary_pax_records.insert(
8696            "TZAP.macos.st-flags".into(),
8697            "0000000000040000".into(), // SF_IMMUTABLE
8698        );
8699        assert!(native_metadata_requires_system_restore(&native, "macos"));
8700
8701        let mut native = NativeFileMetadata::default();
8702        native
8703            .primary_pax_records
8704            .insert("SCHILY.fflags".into(), "uchg,uappnd".into());
8705        assert!(native_metadata_requires_system_restore(&native, "linux"));
8706    }
8707
8708    #[test]
8709    fn windows_reparse_and_attributes_require_system_restore() {
8710        let mut native = NativeFileMetadata::default();
8711        native
8712            .primary_pax_records
8713            .insert("TZAP.windows.reparse-placeholder".into(), "1".into());
8714        assert!(native_metadata_requires_system_restore(&native, "windows"));
8715
8716        let mut native = NativeFileMetadata::default();
8717        native.primary_pax_records.insert(
8718            "TZAP.windows.data-stream-attributes".into(),
8719            "00000002".into(), // stream contains security
8720        );
8721        assert!(native_metadata_requires_system_restore(&native, "windows"));
8722    }
8723
8724    #[test]
8725    fn regular_file_writer_round_trips_portable_owner_origin_and_attributes() {
8726        let portable_metadata = PortableFileMetadata {
8727            source_os: "other-unix".into(),
8728            source_filesystem: "ext4".into(),
8729            mode_origin: PortableModeOrigin::Native,
8730            posix_owner: Some(PortablePosixOwner {
8731                uid: 9_000_000,
8732                gid: 42,
8733                uname: Some("tést-user".into()),
8734                gname: Some("archive".into()),
8735            }),
8736            attributes: Some(1),
8737            native: NativeFileMetadata::default(),
8738        };
8739        let group = build_regular_file_member_group(
8740            b"owned.txt",
8741            b"owned",
8742            0o640,
8743            ArchiveTimestamp::UNIX_EPOCH,
8744            &portable_metadata,
8745        )
8746        .unwrap();
8747        let parsed = parse_tar_member_group(&group, 4096).unwrap();
8748
8749        assert!(parsed.v45_metadata.declaration.owner_kind_posix);
8750        assert!(parsed.v45_metadata.declaration.mode_origin_native);
8751        assert_eq!(parsed.v45_metadata.declaration.source_os, "other-unix");
8752        assert_eq!(parsed.v45_metadata.declaration.source_filesystem, "ext4");
8753        assert_eq!(parsed.v45_metadata.portable_mirror.uid, Some(9_000_000));
8754        assert_eq!(parsed.v45_metadata.portable_mirror.gid, Some(42));
8755        assert_eq!(
8756            parsed.v45_metadata.portable_mirror.uname.as_deref(),
8757            Some("tést-user".as_bytes())
8758        );
8759        assert_eq!(parsed.v45_metadata.portable_mirror.attributes, Some(1));
8760        assert_ne!(
8761            parsed.v45_metadata.file_entry_flags & REQUIRES_SYSTEM_RESTORE,
8762            0
8763        );
8764    }
8765
8766    #[test]
8767    fn directory_writer_emits_type_and_portable_ownership_metadata() {
8768        let portable_metadata = PortableFileMetadata {
8769            source_os: "other-unix".into(),
8770            source_filesystem: "unknown".into(),
8771            mode_origin: PortableModeOrigin::Native,
8772            posix_owner: Some(PortablePosixOwner {
8773                uid: 9_000_000,
8774                gid: 42,
8775                uname: Some("directory-owner".into()),
8776                gname: Some("archive".into()),
8777            }),
8778            attributes: None,
8779            native: NativeFileMetadata::default(),
8780        };
8781        let bytes = build_primary_member_prefix(
8782            b"empty-dir",
8783            SourceEntryKind::Directory,
8784            None,
8785            0,
8786            None,
8787            0o2750,
8788            ArchiveTimestamp::new(1_700_000_000, 123_456_789),
8789            &portable_metadata,
8790        )
8791        .unwrap();
8792        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
8793
8794        assert_eq!(parsed.kind, crate::tar_model::TarEntryKind::Directory);
8795        assert_eq!(parsed.logical_size, 0);
8796        assert_eq!(parsed.v45_metadata.portable_mirror.mode, 0o2750);
8797        assert_eq!(parsed.v45_metadata.portable_mirror.uid, Some(9_000_000));
8798        assert_eq!(parsed.v45_metadata.portable_mirror.gid, Some(42));
8799        assert_eq!(
8800            parsed.v45_metadata.portable_mirror.mtime,
8801            (1_700_000_000, 123_456_789)
8802        );
8803    }
8804
8805    #[test]
8806    fn posix_special_writer_emits_fifo_and_device_primaries() {
8807        let mut portable = PortableFileMetadata {
8808            source_os: "linux".into(),
8809            mode_origin: PortableModeOrigin::Native,
8810            native: NativeFileMetadata {
8811                required_profiles: vec!["posix-backup-v1".into(), "linux-backup-v1".into()],
8812                ..NativeFileMetadata::default()
8813            },
8814            ..PortableFileMetadata::default()
8815        };
8816        let fifo = build_primary_member_prefix(
8817            b"pipe",
8818            SourceEntryKind::Fifo,
8819            None,
8820            0,
8821            None,
8822            0o640,
8823            ArchiveTimestamp::UNIX_EPOCH,
8824            &portable,
8825        )
8826        .unwrap();
8827        assert_eq!(
8828            parse_tar_member_group(&fifo, 4096).unwrap().kind,
8829            crate::tar_model::TarEntryKind::Fifo
8830        );
8831
8832        portable
8833            .native
8834            .primary_pax_records
8835            .insert("TZAP.posix.device-major".into(), b"1".to_vec());
8836        portable
8837            .native
8838            .primary_pax_records
8839            .insert("TZAP.posix.device-minor".into(), b"3".to_vec());
8840        let device = build_primary_member_prefix(
8841            b"null",
8842            SourceEntryKind::CharacterDevice,
8843            None,
8844            0,
8845            None,
8846            0o666,
8847            ArchiveTimestamp::UNIX_EPOCH,
8848            &portable,
8849        )
8850        .unwrap();
8851        assert_eq!(
8852            parse_tar_member_group(&device, 4096).unwrap().kind,
8853            crate::tar_model::TarEntryKind::CharacterDevice
8854        );
8855    }
8856
8857    #[test]
8858    fn symlink_writer_emits_target_and_fractional_mtime() {
8859        let mtime = ArchiveTimestamp::new(1_700_000_321, 654_321_000);
8860        let bytes = build_primary_member_prefix(
8861            b"links/current",
8862            SourceEntryKind::Symlink,
8863            Some(b"../target.txt"),
8864            0,
8865            None,
8866            0o777,
8867            mtime,
8868            &PortableFileMetadata::default(),
8869        )
8870        .unwrap();
8871        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
8872
8873        assert_eq!(parsed.kind, crate::tar_model::TarEntryKind::Symlink);
8874        assert_eq!(
8875            parsed.link_target.as_deref(),
8876            Some(b"../target.txt".as_slice())
8877        );
8878        assert_eq!(parsed.mtime, mtime);
8879        assert_eq!(
8880            parsed.v45_metadata.portable_mirror.mtime,
8881            (mtime.seconds, mtime.nanoseconds)
8882        );
8883    }
8884
8885    #[test]
8886    fn hardlink_writer_emits_zero_data_alias_with_portable_mirror_only() {
8887        let portable = PortableFileMetadata {
8888            source_os: "other-unix".into(),
8889            source_filesystem: "ext4".into(),
8890            mode_origin: PortableModeOrigin::Native,
8891            posix_owner: Some(PortablePosixOwner {
8892                uid: 1000,
8893                gid: 100,
8894                uname: Some("owner".into()),
8895                gname: Some("group".into()),
8896            }),
8897            attributes: Some(1),
8898            native: NativeFileMetadata::default(),
8899        };
8900        let bytes = build_primary_member_prefix(
8901            b"aliases/beta",
8902            SourceEntryKind::Hardlink,
8903            Some(b"aliases/alpha"),
8904            0,
8905            None,
8906            0o640,
8907            ArchiveTimestamp::new(1_700_000_000, 123_456_700),
8908            &portable,
8909        )
8910        .unwrap();
8911        let parsed = parse_tar_member_group(&bytes, 4096).unwrap();
8912        assert_eq!(parsed.kind, crate::tar_model::TarEntryKind::Hardlink);
8913        assert_eq!(parsed.logical_size, 0);
8914        assert_eq!(
8915            parsed.link_target.as_deref(),
8916            Some(b"aliases/alpha".as_slice())
8917        );
8918        assert_eq!(
8919            parsed.v45_metadata.declaration.required_profiles,
8920            ["portable-v1"]
8921        );
8922        assert!(parsed.v45_metadata.auxiliary.is_empty());
8923        assert_eq!(parsed.v45_metadata.portable_mirror.mode, 0o640);
8924        assert_eq!(parsed.v45_metadata.portable_mirror.uid, Some(1000));
8925        assert_eq!(parsed.v45_metadata.portable_mirror.attributes, Some(1));
8926    }
8927
8928    #[test]
8929    fn hardlink_writer_rejects_native_file_object_metadata() {
8930        let mut portable = PortableFileMetadata::default();
8931        portable
8932            .native
8933            .primary_pax_records
8934            .insert("TZAP.unix.ctime-observed".into(), b"1".to_vec());
8935        assert!(matches!(
8936            build_primary_member_prefix(
8937                b"beta",
8938                SourceEntryKind::Hardlink,
8939                Some(b"alpha"),
8940                0,
8941                None,
8942                0o644,
8943                ArchiveTimestamp::UNIX_EPOCH,
8944                &portable,
8945            ),
8946            Err(FormatError::WriterInvariant(
8947                "hardlink alias carries native file-object metadata"
8948            ))
8949        ));
8950    }
8951
8952    #[test]
8953    fn regular_file_writer_rejects_reserved_portable_attribute_bits() {
8954        let portable_metadata = PortableFileMetadata {
8955            attributes: Some(1 << 4),
8956            ..PortableFileMetadata::default()
8957        };
8958        assert_eq!(
8959            build_regular_file_member_group(
8960                b"attributes.txt",
8961                b"data",
8962                0o644,
8963                ArchiveTimestamp::UNIX_EPOCH,
8964                &portable_metadata,
8965            )
8966            .unwrap_err(),
8967            FormatError::WriterUnsupported("portable attributes contain reserved bits")
8968        );
8969    }
8970
8971    #[test]
8972    fn regular_file_writer_emits_and_flags_valid_native_primary_metadata() {
8973        let mut native = NativeFileMetadata {
8974            required_profiles: vec!["posix-backup-v1".into()],
8975            ..NativeFileMetadata::default()
8976        };
8977        native.primary_pax_records.insert(
8978            "LIBARCHIVE.xattr.user.comment".into(),
8979            crate::entry_metadata::canonical_base64_encode(b"preserved"),
8980        );
8981        let metadata = PortableFileMetadata {
8982            source_os: "linux".into(),
8983            source_filesystem: "ext4".into(),
8984            native,
8985            ..PortableFileMetadata::default()
8986        };
8987        let group = build_regular_file_member_group(
8988            b"native.txt",
8989            b"data",
8990            0o640,
8991            ArchiveTimestamp::UNIX_EPOCH,
8992            &metadata,
8993        )
8994        .unwrap();
8995        let parsed = parse_tar_member_group(&group, 4096).unwrap();
8996
8997        assert_eq!(
8998            parsed.v45_metadata.declaration.required_profiles,
8999            vec!["portable-v1", "posix-backup-v1"]
9000        );
9001        assert_eq!(
9002            parsed
9003                .v45_metadata
9004                .primary_records
9005                .get("LIBARCHIVE.xattr.user.comment")
9006                .map(Vec::as_slice),
9007            Some(b"cHJlc2VydmVk".as_slice())
9008        );
9009        assert_ne!(
9010            parsed.v45_metadata.file_entry_flags & HAS_NATIVE_METADATA,
9011            0
9012        );
9013    }
9014
9015    #[test]
9016    fn regular_file_writer_emits_valid_native_auxiliary_metadata() {
9017        let mut native = NativeFileMetadata {
9018            required_profiles: vec!["posix-backup-v1".into()],
9019            ..NativeFileMetadata::default()
9020        };
9021        let mut auxiliary = NativeAuxiliaryMetadata::new(
9022            "generic.xattr",
9023            "posix-backup-v1",
9024            RestoreClass::SameOs,
9025            b"large xattr value".to_vec(),
9026        );
9027        auxiliary.name_encoding = NativeAuxiliaryNameEncoding::Bytes;
9028        auxiliary.name = b"user.large".to_vec();
9029        native.auxiliary_records.push(auxiliary);
9030        let metadata = PortableFileMetadata {
9031            source_os: "linux".into(),
9032            native,
9033            ..PortableFileMetadata::default()
9034        };
9035
9036        let group = build_regular_file_member_group(
9037            b"native-aux.txt",
9038            b"contents",
9039            0o640,
9040            ArchiveTimestamp::from_seconds(12),
9041            &metadata,
9042        )
9043        .unwrap();
9044        let parsed = parse_tar_member_group(&group, 4096).unwrap();
9045
9046        assert_eq!(parsed.v45_metadata.auxiliary.len(), 1);
9047        assert_eq!(parsed.v45_metadata.auxiliary[0].kind, "generic.xattr");
9048        assert_eq!(parsed.v45_metadata.auxiliary[0].decoded_name, b"user.large");
9049        assert_ne!(
9050            parsed.v45_metadata.file_entry_flags & HAS_AUXILIARY_STREAMS,
9051            0
9052        );
9053    }
9054
9055    #[test]
9056    fn regular_file_writer_emits_capture_partial_flag_when_capture_report_present() {
9057        let mut native = NativeFileMetadata::default();
9058        let mut auxiliary = NativeAuxiliaryMetadata::new(
9059            "tzap.capture-report",
9060            "tzap-core-v1",
9061            RestoreClass::None,
9062            b"tzap-capture-report-v1\nportable-v1\ttzap-core-v1\texcluded-policy\tdetail\n"
9063                .to_vec(),
9064        );
9065        auxiliary.native = false;
9066        auxiliary.name_encoding = NativeAuxiliaryNameEncoding::None;
9067        auxiliary.name = vec![];
9068        native.auxiliary_records.push(auxiliary);
9069        let metadata = PortableFileMetadata {
9070            source_os: "linux".into(),
9071            native,
9072            ..PortableFileMetadata::default()
9073        };
9074
9075        let group = build_regular_file_member_group(
9076            b"capture-report.txt",
9077            b"contents",
9078            0o640,
9079            ArchiveTimestamp::from_seconds(12),
9080            &metadata,
9081        )
9082        .unwrap();
9083        let parsed = parse_tar_member_group(&group, 4096).unwrap();
9084
9085        assert_eq!(parsed.v45_metadata.auxiliary.len(), 1);
9086        assert_eq!(parsed.v45_metadata.auxiliary[0].kind, "tzap.capture-report");
9087        assert_ne!(parsed.v45_metadata.file_entry_flags & CAPTURE_PARTIAL, 0);
9088    }
9089
9090    #[test]
9091    fn streamed_auxiliary_sources_work_across_writer_modes_and_verify_digest() {
9092        struct StreamedSource {
9093            metadata: PortableFileMetadata,
9094            payload: Vec<u8>,
9095        }
9096
9097        impl RegularFileSource for StreamedSource {
9098            fn archive_path(&self) -> &str {
9099                "streamed-aux.txt"
9100            }
9101
9102            fn file_data_size(&self) -> u64 {
9103                8
9104            }
9105
9106            fn mode(&self) -> u32 {
9107                0o640
9108            }
9109
9110            fn mtime(&self) -> ArchiveTimestamp {
9111                ArchiveTimestamp::from_seconds(12)
9112            }
9113
9114            fn portable_metadata(&self) -> PortableFileMetadata {
9115                self.metadata.clone()
9116            }
9117
9118            fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
9119                Ok(Box::new(Cursor::new(b"contents".as_slice())))
9120            }
9121
9122            fn open_auxiliary(
9123                &self,
9124                ordinal: usize,
9125            ) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
9126                assert_eq!(ordinal, 0);
9127                Ok(Box::new(Cursor::new(self.payload.as_slice())))
9128            }
9129        }
9130
9131        let payload = deterministic_bytes(1024 * 1024 + 17);
9132        let digest: [u8; 32] = Sha256::digest(&payload).into();
9133        let mut auxiliary = NativeAuxiliaryMetadata::new_streamed(
9134            "generic.xattr",
9135            "posix-backup-v1",
9136            RestoreClass::SameOs,
9137            payload.len() as u64,
9138            digest,
9139        );
9140        auxiliary.name_encoding = NativeAuxiliaryNameEncoding::Bytes;
9141        auxiliary.name = b"user.large".to_vec();
9142        let mut source = StreamedSource {
9143            metadata: PortableFileMetadata {
9144                source_os: "linux".into(),
9145                native: NativeFileMetadata {
9146                    required_profiles: vec!["posix-backup-v1".into()],
9147                    auxiliary_records: vec![auxiliary],
9148                    ..NativeFileMetadata::default()
9149                },
9150                ..PortableFileMetadata::default()
9151            },
9152            payload,
9153        };
9154        let key = MasterKey::from_raw_key(&[17u8; 32]).unwrap();
9155        let options = WriterOptions {
9156            stripe_width: 1,
9157            volume_loss_tolerance: 0,
9158            bit_rot_buffer_pct: 0,
9159            ..WriterOptions::default()
9160        };
9161
9162        let mut two_pass = MemoryArchiveSink::default();
9163        write_archive_sources_to_sink(
9164            std::slice::from_ref(&source),
9165            &key,
9166            options,
9167            None,
9168            &KdfParams::Raw,
9169            None,
9170            None,
9171            &mut two_pass,
9172        )
9173        .unwrap();
9174        open_archive(&two_pass.volumes[0], &key)
9175            .unwrap()
9176            .verify_content()
9177            .unwrap();
9178
9179        let mut single_pass = MemoryArchiveSink::default();
9180        write_archive_sources_to_sink_single_pass(
9181            std::slice::from_ref(&source),
9182            &key,
9183            options,
9184            &KdfParams::Raw,
9185            None,
9186            None,
9187            &mut single_pass,
9188        )
9189        .unwrap();
9190        open_archive(&single_pass.volumes[0], &key)
9191            .unwrap()
9192            .verify_content()
9193            .unwrap();
9194
9195        let mut ordered = MemoryArchiveSink::default();
9196        write_archive_sources_to_sink_ordered_parallel(
9197            std::slice::from_ref(&source),
9198            &key,
9199            options,
9200            &KdfParams::Raw,
9201            None,
9202            None,
9203            &mut ordered,
9204        )
9205        .unwrap();
9206        open_archive(&ordered.volumes[0], &key)
9207            .unwrap()
9208            .verify_content()
9209            .unwrap();
9210
9211        let mut progress_sink = MemoryArchiveSink::default();
9212        let mut progress = RecordingWriteProgress::default();
9213        write_archive_sources_to_sink_single_pass_with_progress(
9214            std::slice::from_ref(&source),
9215            &key,
9216            options,
9217            &KdfParams::Raw,
9218            None,
9219            None,
9220            &mut progress_sink,
9221            &mut progress,
9222        )
9223        .unwrap();
9224        assert_eq!(
9225            progress.bytes_for(ArchiveWritePhase::EmittingPayload),
9226            8 + source.payload.len() as u64
9227        );
9228
9229        source.metadata.native.auxiliary_records[0]
9230            .streamed_payload
9231            .as_mut()
9232            .unwrap()
9233            .sha256[0] ^= 1;
9234        let mut rejected = MemoryArchiveSink::default();
9235        assert!(write_archive_sources_to_sink(
9236            std::slice::from_ref(&source),
9237            &key,
9238            options,
9239            None,
9240            &KdfParams::Raw,
9241            None,
9242            None,
9243            &mut rejected,
9244        )
9245        .is_err());
9246    }
9247
9248    #[test]
9249    fn writer_splits_large_payload_across_seekable_envelopes() {
9250        let master_key = MasterKey::from_raw_key(&[8u8; 32]).unwrap();
9251        let data = deterministic_bytes(2 * 1024 * 1024);
9252        let archive = write_archive(
9253            &[RegularFile::new("large.bin", &data)],
9254            &master_key,
9255            WriterOptions {
9256                stripe_width: 1,
9257                volume_loss_tolerance: 0,
9258                bit_rot_buffer_pct: 0,
9259                ..WriterOptions::default()
9260            },
9261        )
9262        .unwrap();
9263        let opened = open_archive(&archive.bytes, &master_key).unwrap();
9264
9265        assert_eq!(opened.list_files().unwrap()[0].path, "large.bin");
9266        assert_eq!(opened.extract_file("large.bin").unwrap(), Some(data));
9267        opened.verify().unwrap();
9268        assert!(opened.index_root.header.envelope_count > 1);
9269    }
9270
9271    #[test]
9272    fn split_member_frames_carry_exact_boundary_flags() {
9273        let data = deterministic_bytes(12 * 1024);
9274        let files = [RegularFile::new("large.bin", &data)];
9275        let options = WriterOptions {
9276            chunk_size: 1024,
9277            envelope_target_size: 64 * 1024,
9278            stripe_width: 1,
9279            volume_loss_tolerance: 0,
9280            bit_rot_buffer_pct: 0,
9281            ..WriterOptions::default()
9282        };
9283        let (tar_stream, members) = build_tar_stream(&files, options.max_path_length).unwrap();
9284        let (_, frames) = build_payload_envelopes(&tar_stream, &members, options, None).unwrap();
9285
9286        assert!(frames.len() > 2);
9287        assert_eq!(frames.first().unwrap().flags, 0x0000_0001);
9288        assert_eq!(frames.last().unwrap().flags, 0x0000_0002);
9289        assert!(frames[1..frames.len() - 1]
9290            .iter()
9291            .all(|frame| frame.flags == 0));
9292    }
9293
9294    #[test]
9295    fn spanning_payload_derives_exact_multi_frame_multi_envelope_stats() {
9296        let data = deterministic_bytes(13 * 1024);
9297        let files = [RegularFile::new("spanning.bin", &data)];
9298        let options = plan_writer_options(WriterOptions {
9299            block_size: MIN_BLOCK_SIZE,
9300            chunk_size: 1024,
9301            envelope_target_size: 2500,
9302            stripe_width: 1,
9303            volume_loss_tolerance: 0,
9304            bit_rot_buffer_pct: 0,
9305            fec_parity_shards: 0,
9306            index_fec_parity_shards: 0,
9307            index_root_fec_parity_shards: 0,
9308            ..WriterOptions::default()
9309        })
9310        .unwrap();
9311        let mut next_block_index = 0u64;
9312
9313        let payload = plan_payload_stream(&files, options, None, &mut next_block_index).unwrap();
9314        let expected_tar_total_size =
9315            3 * TAR_BLOCK_LEN as u64 + data.len() as u64 + padding_to_512(data.len()) as u64;
9316        let (tar_stream, _) = build_tar_stream(&files, options.max_path_length).unwrap();
9317
9318        assert_eq!(payload.tar_members.len(), 1);
9319        assert_eq!(payload.tar_members[0].tar_member_group_start, 0);
9320        assert_eq!(
9321            payload.tar_members[0].tar_member_group_size,
9322            expected_tar_total_size
9323        );
9324        assert_eq!(payload.tar_total_size, expected_tar_total_size);
9325        assert_eq!(payload.content_sha256, sha256_bytes(&tar_stream));
9326        assert_eq!(payload.frames.len(), 15);
9327        assert_eq!(payload.payload_objects.len(), 7);
9328        assert_eq!(payload.payload_block_count, 7);
9329        assert_eq!(next_block_index, 7);
9330
9331        for (idx, frame) in payload.frames.iter().enumerate() {
9332            let expected_decompressed_size = if idx == 14 { 512 } else { 1024 };
9333            let expected_flags = match idx {
9334                0 => 0x0000_0001,
9335                14 => 0x0000_0002,
9336                _ => 0,
9337            };
9338            let expected_offset = payload.frames[..idx]
9339                .iter()
9340                .filter(|prior| prior.envelope_index == frame.envelope_index)
9341                .map(|prior| prior.compressed_size)
9342                .sum::<u32>();
9343
9344            assert_eq!(frame.frame_index, idx as u64);
9345            assert_eq!(frame.member_index, 0);
9346            assert!((frame.envelope_index as usize) < payload.payload_objects.len());
9347            assert_eq!(frame.offset_in_envelope, expected_offset);
9348            assert_eq!(frame.decompressed_size, expected_decompressed_size);
9349            assert_eq!(frame.flags, expected_flags);
9350            assert_eq!(frame.tar_stream_offset, idx as u64 * 1024);
9351        }
9352
9353        for (idx, object) in payload.payload_objects.iter().enumerate() {
9354            let expected_plaintext_size = payload
9355                .frames
9356                .iter()
9357                .filter(|frame| frame.envelope_index == idx as u64)
9358                .map(|frame| frame.compressed_size)
9359                .sum::<u32>();
9360
9361            assert_eq!(object.envelope_index, idx as u64);
9362            assert_eq!(object.plaintext_size, expected_plaintext_size);
9363            assert_eq!(object.object.first_block_index, idx as u64);
9364            assert_eq!(object.object.data_block_count, 1);
9365            assert_eq!(object.object.parity_block_count, 0);
9366            assert_eq!(object.object.encrypted_size, options.block_size);
9367        }
9368
9369        let shard_rows = partition_file_rows(sorted_file_rows(&payload.tar_members)).unwrap();
9370        let planned_shards = build_index_shard_plaintexts(
9371            &shard_rows,
9372            &payload.frames,
9373            &payload.payload_objects,
9374            options,
9375        )
9376        .unwrap();
9377        assert_eq!(planned_shards.len(), 1);
9378        let locating_shard = ShardEntry {
9379            shard_index: planned_shards[0].shard_index,
9380            first_block_index: 0,
9381            data_block_count: 1,
9382            parity_block_count: 0,
9383            encrypted_size: options.block_size,
9384            decompressed_size: planned_shards[0].plaintext.len() as u32,
9385            file_count: planned_shards[0].file_count,
9386            first_path_hash: planned_shards[0].first_path_hash,
9387            last_path_hash: planned_shards[0].last_path_hash,
9388        };
9389        let shard = IndexShard::parse(
9390            &planned_shards[0].plaintext,
9391            &locating_shard,
9392            MetadataLimits::default(),
9393        )
9394        .unwrap();
9395        assert_eq!(shard.header.file_count, 1);
9396        assert_eq!(shard.header.frame_count, 15);
9397        assert_eq!(shard.header.envelope_count, 7);
9398        assert_eq!(shard.files[0].first_frame_index, 0);
9399        assert_eq!(shard.files[0].frame_count, 15);
9400        assert_eq!(
9401            shard.files[0].tar_member_group_size,
9402            expected_tar_total_size
9403        );
9404        assert_eq!(shard.files[0].file_data_size, data.len() as u64);
9405
9406        for (idx, frame) in shard.frames.iter().enumerate() {
9407            assert_eq!(frame.frame_index, idx as u64);
9408            assert_eq!(frame.envelope_index, payload.frames[idx].envelope_index);
9409            assert_eq!(
9410                frame.offset_in_envelope,
9411                payload.frames[idx].offset_in_envelope
9412            );
9413            assert_eq!(frame.compressed_size, payload.frames[idx].compressed_size);
9414            assert_eq!(
9415                frame.decompressed_size,
9416                payload.frames[idx].decompressed_size
9417            );
9418            assert_eq!(frame.flags, payload.frames[idx].flags);
9419            assert_eq!(
9420                frame.tar_stream_offset,
9421                payload.frames[idx].tar_stream_offset
9422            );
9423        }
9424
9425        for (idx, envelope) in shard.envelopes.iter().enumerate() {
9426            assert_eq!(envelope.envelope_index, idx as u64);
9427            assert_eq!(envelope.first_block_index, idx as u64);
9428            assert_eq!(envelope.data_block_count, 1);
9429            assert_eq!(envelope.parity_block_count, 0);
9430            assert_eq!(envelope.encrypted_size, options.block_size);
9431            assert_eq!(
9432                envelope.plaintext_size,
9433                payload.payload_objects[idx].plaintext_size
9434            );
9435            let envelope_frames: Vec<_> = payload
9436                .frames
9437                .iter()
9438                .filter(|frame| frame.envelope_index == idx as u64)
9439                .collect();
9440            assert_eq!(
9441                envelope.first_frame_index,
9442                envelope_frames.first().unwrap().frame_index
9443            );
9444            assert_eq!(envelope.frame_count, envelope_frames.len() as u32);
9445        }
9446
9447        let master_key = MasterKey::from_raw_key(&[6u8; 32]).unwrap();
9448        let plan = build_writer_plan_from_payload(
9449            payload,
9450            next_block_index,
9451            &master_key,
9452            options,
9453            None,
9454            &KdfParams::Raw,
9455            None,
9456            [0x44; 16],
9457            [0x55; 16],
9458            None,
9459        )
9460        .unwrap();
9461        let index_root =
9462            IndexRoot::parse(&plan.index_root_plaintext, false, MetadataLimits::default()).unwrap();
9463
9464        assert_eq!(index_root.header.frame_count, 15);
9465        assert_eq!(index_root.header.envelope_count, 7);
9466        assert_eq!(index_root.header.file_count, 1);
9467        assert_eq!(index_root.header.payload_block_count, 7);
9468        assert_eq!(index_root.header.tar_total_size, expected_tar_total_size);
9469        assert_eq!(index_root.header.content_sha256, sha256_bytes(&tar_stream));
9470    }
9471
9472    #[test]
9473    fn writes_empty_archive_with_authentic_bootstrap_structures() {
9474        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
9475        let archive = write_empty_archive(&master_key).unwrap();
9476        let bytes = archive.bytes;
9477
9478        let volume_header = VolumeHeader::parse(&bytes[..VOLUME_HEADER_LEN]).unwrap();
9479        assert_eq!(volume_header.archive_uuid, archive.archive_uuid);
9480        assert_eq!(volume_header.session_id, archive.session_id);
9481
9482        let crypto_start = VOLUME_HEADER_LEN;
9483        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
9484        let crypto_header = CryptoHeader::parse(
9485            &bytes[crypto_start..crypto_end],
9486            volume_header.crypto_header_length,
9487        )
9488        .unwrap();
9489        let subkeys =
9490            Subkeys::derive(&master_key, &archive.archive_uuid, &archive.session_id).unwrap();
9491        verify_hmac(
9492            HmacDomain::CryptoHeader,
9493            &subkeys.mac_key,
9494            &archive.archive_uuid,
9495            &archive.session_id,
9496            crypto_header.hmac_covered_bytes,
9497            &crypto_header.header_hmac,
9498        )
9499        .unwrap();
9500
9501        let locator =
9502            CriticalRecoveryLocator::parse(&bytes[bytes.len() - CRITICAL_RECOVERY_LOCATOR_LEN..])
9503                .unwrap();
9504        let trailer_offset = locator.volume_trailer_offset as usize;
9505        let trailer =
9506            VolumeTrailer::parse(&bytes[trailer_offset..trailer_offset + VOLUME_TRAILER_LEN])
9507                .unwrap();
9508        assert_eq!(trailer.bytes_written, trailer_offset as u64);
9509        verify_hmac(
9510            HmacDomain::VolumeTrailer,
9511            &subkeys.mac_key,
9512            &archive.archive_uuid,
9513            &archive.session_id,
9514            &bytes[trailer_offset..trailer_offset + 96],
9515            &trailer.trailer_hmac,
9516        )
9517        .unwrap();
9518
9519        let manifest_offset = trailer.manifest_footer_offset as usize;
9520        let manifest_end = manifest_offset + MANIFEST_FOOTER_LEN;
9521        let manifest = ManifestFooter::parse(&bytes[manifest_offset..manifest_end]).unwrap();
9522        assert_eq!(manifest.is_authoritative, 1);
9523        assert_eq!(manifest.total_volumes, DEFAULT_STRIPE_WIDTH);
9524        verify_hmac(
9525            HmacDomain::ManifestFooter,
9526            &subkeys.mac_key,
9527            &archive.archive_uuid,
9528            &archive.session_id,
9529            &bytes[manifest_offset..manifest_offset + 104],
9530            &manifest.manifest_hmac,
9531        )
9532        .unwrap();
9533    }
9534
9535    #[test]
9536    fn parity_auto_scaling_matches_v41_examples() {
9537        let options = WriterOptions {
9538            fec_data_shards: 224,
9539            stripe_width: 8,
9540            volume_loss_tolerance: 1,
9541            bit_rot_buffer_pct: 5,
9542            ..WriterOptions::default()
9543        };
9544
9545        assert_eq!(compute_parity(224, options).unwrap(), 48);
9546        assert_eq!(compute_parity(17, options).unwrap(), 5);
9547    }
9548
9549    #[test]
9550    fn parity_auto_scaling_rejects_non_convergent_budget() {
9551        let err = compute_parity(
9552            1,
9553            WriterOptions {
9554                stripe_width: 2,
9555                volume_loss_tolerance: 1,
9556                bit_rot_buffer_pct: 50,
9557                ..WriterOptions::default()
9558            },
9559        )
9560        .unwrap_err();
9561
9562        assert_eq!(
9563            err,
9564            FormatError::WriterUnsupported("parity calculation did not converge")
9565        );
9566    }
9567
9568    #[test]
9569    fn zero_parity_is_allowed_when_no_recovery_margin_is_requested() {
9570        let planned = plan_writer_options(WriterOptions {
9571            bit_rot_buffer_pct: 0,
9572            stripe_width: 1,
9573            volume_loss_tolerance: 0,
9574            fec_parity_shards: 0,
9575            index_fec_parity_shards: 0,
9576            index_root_fec_parity_shards: 0,
9577            ..WriterOptions::default()
9578        })
9579        .unwrap();
9580
9581        assert_eq!(planned.fec_parity_shards, 0);
9582        assert_eq!(planned.index_fec_parity_shards, 0);
9583        assert_eq!(planned.index_root_fec_parity_shards, 0);
9584        assert_eq!(compute_parity(1, planned).unwrap(), 0);
9585    }
9586
9587    #[test]
9588    fn index_root_data_shard_maximum_obeys_v41_minimum() {
9589        let planned = plan_writer_options(WriterOptions {
9590            index_root_fec_data_shards: 1,
9591            ..WriterOptions::default()
9592        })
9593        .unwrap();
9594
9595        assert_eq!(
9596            planned.index_root_fec_data_shards,
9597            MIN_INDEX_ROOT_FEC_DATA_SHARDS
9598        );
9599    }
9600
9601    #[test]
9602    fn metadata_class_planning_raises_index_root_class_above_default() {
9603        let options = plan_writer_options(WriterOptions {
9604            block_size: MIN_BLOCK_SIZE,
9605            index_root_fec_parity_shards: 0,
9606            bit_rot_buffer_pct: 0,
9607            ..WriterOptions::default()
9608        })
9609        .unwrap();
9610        let index_root_payload_len = payload_len_for_encrypted_data_blocks(64, options);
9611
9612        let planned =
9613            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap();
9614
9615        assert_eq!(planned.index_root.data_block_count, 64);
9616        assert_eq!(planned.options.index_root_fec_data_shards, 64);
9617        assert_eq!(
9618            planned.options.index_root_fec_parity_shards,
9619            compute_parity_u16(
9620                planned.options.index_root_fec_data_shards as u64,
9621                planned.options,
9622                "index_root_fec_parity_shards",
9623            )
9624            .unwrap()
9625        );
9626    }
9627
9628    #[test]
9629    fn single_pass_writer_predeclares_metadata_class_before_payload_streaming() {
9630        let planned = plan_single_pass_writer_options(WriterOptions {
9631            block_size: MIN_BLOCK_SIZE,
9632            stripe_width: 1,
9633            volume_loss_tolerance: 0,
9634            bit_rot_buffer_pct: 0,
9635            index_root_fec_parity_shards: 0,
9636            ..WriterOptions::default()
9637        })
9638        .unwrap();
9639
9640        assert!(planned.index_root_fec_data_shards > DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS);
9641        let index_root_payload_len = payload_len_for_encrypted_data_blocks(
9642            u32::from(planned.index_root_fec_data_shards - 1),
9643            planned,
9644        );
9645        let metadata_class =
9646            plan_index_root_metadata_class(planned, index_root_payload_len, None).unwrap();
9647
9648        assert_eq!(metadata_class.options, planned);
9649    }
9650
9651    #[test]
9652    fn metadata_class_planning_rejects_oversized_index_root() {
9653        let options = single_volume_metadata_test_options();
9654        let index_root_payload_len =
9655            payload_len_for_encrypted_data_blocks(u16::MAX as u32 + 1, options);
9656
9657        let err =
9658            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap_err();
9659
9660        assert_eq!(err, FormatError::WriterUnsupported("IndexRoot too large"));
9661    }
9662
9663    #[test]
9664    fn metadata_class_planning_rejects_index_root_u32_encrypted_size_overflow() {
9665        let options = single_volume_metadata_test_options();
9666        let index_root_payload_len = u32::MAX as usize - options.aead_algo.tag_len() + 1;
9667
9668        let err =
9669            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap_err();
9670
9671        assert_eq!(err, FormatError::WriterUnsupported("IndexRoot too large"));
9672    }
9673
9674    #[test]
9675    fn metadata_class_planning_rejects_oversized_dictionary() {
9676        let options = single_volume_metadata_test_options();
9677        let dictionary_payload_len =
9678            payload_len_for_encrypted_data_blocks(u16::MAX as u32 + 1, options);
9679
9680        let err =
9681            plan_index_root_metadata_class(options, 1, Some(dictionary_payload_len)).unwrap_err();
9682
9683        assert_eq!(
9684            err,
9685            FormatError::WriterUnsupported("dictionary object too large")
9686        );
9687    }
9688
9689    #[test]
9690    fn metadata_class_planning_rejects_gf16_total_overflow_for_dictionary() {
9691        let options = plan_writer_options(WriterOptions {
9692            block_size: MIN_BLOCK_SIZE,
9693            stripe_width: 8,
9694            volume_loss_tolerance: 1,
9695            bit_rot_buffer_pct: 5,
9696            ..WriterOptions::default()
9697        })
9698        .unwrap();
9699        let dictionary_payload_len = payload_len_for_encrypted_data_blocks(60_000, options);
9700
9701        let err =
9702            plan_index_root_metadata_class(options, 1, Some(dictionary_payload_len)).unwrap_err();
9703
9704        assert_eq!(
9705            err,
9706            FormatError::WriterUnsupported("dictionary object too large")
9707        );
9708    }
9709
9710    #[test]
9711    fn written_archive_authenticates_final_index_root_fec_class() {
9712        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
9713        let dictionary = deterministic_bytes(80 * 1024);
9714        let file = RegularFile::new("uses-dictionary.txt", b"payload");
9715        let archive = write_archive_with_dictionary(
9716            &[file],
9717            &master_key,
9718            WriterOptions {
9719                block_size: MIN_BLOCK_SIZE,
9720                stripe_width: 1,
9721                volume_loss_tolerance: 0,
9722                bit_rot_buffer_pct: 0,
9723                index_root_fec_parity_shards: 0,
9724                ..WriterOptions::default()
9725            },
9726            &dictionary,
9727        )
9728        .unwrap();
9729
9730        let volume_header = VolumeHeader::parse(&archive.bytes[..VOLUME_HEADER_LEN]).unwrap();
9731        let crypto_start = VOLUME_HEADER_LEN;
9732        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
9733        let crypto_header = CryptoHeader::parse(
9734            &archive.bytes[crypto_start..crypto_end],
9735            volume_header.crypto_header_length,
9736        )
9737        .unwrap();
9738        let subkeys =
9739            Subkeys::derive(&master_key, &archive.archive_uuid, &archive.session_id).unwrap();
9740        verify_hmac(
9741            HmacDomain::CryptoHeader,
9742            &subkeys.mac_key,
9743            &archive.archive_uuid,
9744            &archive.session_id,
9745            crypto_header.hmac_covered_bytes,
9746            &crypto_header.header_hmac,
9747        )
9748        .unwrap();
9749
9750        assert!(crypto_header.fixed.index_root_fec_data_shards > MIN_INDEX_ROOT_FEC_DATA_SHARDS);
9751        assert_eq!(crypto_header.fixed.index_root_fec_parity_shards, 0);
9752        let opened = open_archive(&archive.bytes, &master_key).unwrap();
9753        assert_eq!(
9754            opened.extract_file("uses-dictionary.txt").unwrap(),
9755            Some(b"payload".to_vec())
9756        );
9757        opened.verify().unwrap();
9758    }
9759
9760    #[test]
9761    fn object_parity_uses_per_object_recurrence_even_with_larger_class_max() {
9762        let options = WriterOptions {
9763            bit_rot_buffer_pct: 0,
9764            stripe_width: 1,
9765            volume_loss_tolerance: 0,
9766            fec_parity_shards: 1,
9767            ..WriterOptions::default()
9768        };
9769
9770        assert_eq!(compute_object_parity(1, options, 1).unwrap(), 0);
9771    }
9772
9773    #[test]
9774    fn object_total_shards_obeys_reed_solomon_limit() {
9775        assert!(validate_object_shard_total(65_535, 0).is_ok());
9776        assert_eq!(
9777            validate_object_shard_total(65_535, 1).unwrap_err(),
9778            FormatError::WriterUnsupported("encrypted object exceeds ReedSolomonGF16 shard limit")
9779        );
9780    }
9781
9782    #[test]
9783    fn argon2id_kdf_serialization_rejects_memory_requirement_overflow() {
9784        assert_eq!(
9785            serialize_kdf_params(&KdfParams::Argon2id {
9786                t_cost: 1,
9787                m_cost_kib: u32::MAX,
9788                parallelism: u32::MAX,
9789                salt: b"12345678".to_vec(),
9790            })
9791            .unwrap_err(),
9792            FormatError::InvalidKdfParams("m_cost_kib requirement overflow")
9793        );
9794    }
9795
9796    #[test]
9797    fn recipient_wrap_kdf_serialization() {
9798        let params = KdfParams::RecipientWrap {
9799            key_wrap_table_length: 16,
9800            key_wrap_table_record_count: 4,
9801            key_wrap_table_version: 1,
9802            key_wrap_table_digest: [0xaau8; 32],
9803        };
9804        let serialized = serialize_kdf_params(&params).unwrap();
9805        let mut expected = Vec::new();
9806        expected.extend_from_slice(&(KdfAlgo::RecipientWrap as u16).to_le_bytes());
9807        expected.extend_from_slice(&16u32.to_le_bytes());
9808        expected.extend_from_slice(&4u32.to_le_bytes());
9809        expected.extend_from_slice(&1u16.to_le_bytes());
9810        expected.extend_from_slice(&0u16.to_le_bytes());
9811        expected.extend_from_slice(&[0xaau8; 32]);
9812        assert_eq!(serialized, expected);
9813    }
9814
9815    fn recipient_wrap_test_record() -> RecipientRecordV1 {
9816        RecipientRecordV1 {
9817            record_length: 0,
9818            profile_id: 1,
9819            recipient_identity_type: 2,
9820            flags: 0,
9821            recipient_identity_length: 0,
9822            profile_payload_length: 0,
9823            recipient_identity_digest: [0u8; 32],
9824            recipient_identity_bytes: b"recipient-a".to_vec(),
9825            profile_payload_bytes: b"profile-payload".to_vec(),
9826        }
9827    }
9828
9829    #[test]
9830    fn writer_options_reject_reader_resource_cap_excesses() {
9831        assert_eq!(
9832            plan_writer_options(WriterOptions {
9833                stripe_width: crate::format::READER_MAX_STRIPE_WIDTH + 1,
9834                volume_loss_tolerance: 0,
9835                ..WriterOptions::default()
9836            })
9837            .unwrap_err(),
9838            FormatError::ReaderResourceLimitExceeded {
9839                field: "stripe_width",
9840                cap: crate::format::READER_MAX_STRIPE_WIDTH as u64,
9841                actual: crate::format::READER_MAX_STRIPE_WIDTH as u64 + 1,
9842            }
9843        );
9844        assert_eq!(
9845            plan_writer_options(WriterOptions {
9846                block_size: crate::format::READER_MAX_BLOCK_SIZE + 2,
9847                ..WriterOptions::default()
9848            })
9849            .unwrap_err(),
9850            FormatError::ReaderResourceLimitExceeded {
9851                field: "block_size",
9852                cap: crate::format::READER_MAX_BLOCK_SIZE as u64,
9853                actual: crate::format::READER_MAX_BLOCK_SIZE as u64 + 2,
9854            }
9855        );
9856        assert_eq!(
9857            plan_writer_options(WriterOptions {
9858                chunk_size: crate::format::READER_MAX_CHUNK_SIZE + 1,
9859                envelope_target_size: crate::format::READER_MAX_CHUNK_SIZE + 1,
9860                ..WriterOptions::default()
9861            })
9862            .unwrap_err(),
9863            FormatError::ReaderResourceLimitExceeded {
9864                field: "chunk_size",
9865                cap: crate::format::READER_MAX_CHUNK_SIZE as u64,
9866                actual: crate::format::READER_MAX_CHUNK_SIZE as u64 + 1,
9867            }
9868        );
9869        assert_eq!(
9870            plan_writer_options(WriterOptions {
9871                max_path_length: crate::format::READER_MAX_PATH_LENGTH + 1,
9872                ..WriterOptions::default()
9873            })
9874            .unwrap_err(),
9875            FormatError::ReaderResourceLimitExceeded {
9876                field: "max_path_length",
9877                cap: crate::format::READER_MAX_PATH_LENGTH as u64,
9878                actual: crate::format::READER_MAX_PATH_LENGTH as u64 + 1,
9879            }
9880        );
9881        assert_eq!(
9882            plan_writer_options(WriterOptions {
9883                bit_rot_buffer_pct: 0,
9884                stripe_width: 1,
9885                volume_loss_tolerance: 0,
9886                fec_data_shards: crate::format::READER_MAX_FEC_CLASS_SHARDS as u16 + 1,
9887                ..WriterOptions::default()
9888            })
9889            .unwrap_err(),
9890            FormatError::ReaderResourceLimitExceeded {
9891                field: "fec_data_shards + fec_parity_shards",
9892                cap: crate::format::READER_MAX_FEC_CLASS_SHARDS as u64,
9893                actual: crate::format::READER_MAX_FEC_CLASS_SHARDS as u64 + 1,
9894            }
9895        );
9896    }
9897
9898    #[test]
9899    fn root_auth_writer_config_rejects_reader_cap_excess_before_authenticator() {
9900        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
9901        let mut authenticator_called = false;
9902        let err = write_archive_with_root_auth(
9903            &[RegularFile::new("signed.txt", b"payload")],
9904            &master_key,
9905            single_volume_metadata_test_options(),
9906            RootAuthWriterConfig {
9907                authenticator_id: 1,
9908                signer_identity_type: 1,
9909                signer_identity: b"signer",
9910                authenticator_value_length: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN + 1,
9911            },
9912            |_| {
9913                authenticator_called = true;
9914                Ok(Vec::new())
9915            },
9916        )
9917        .unwrap_err();
9918
9919        assert!(!authenticator_called);
9920        assert_eq!(
9921            err,
9922            FormatError::ReaderResourceLimitExceeded {
9923                field: "RootAuthFooterV1 authenticator value length",
9924                cap: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as u64,
9925                actual: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as u64 + 1,
9926            }
9927        );
9928    }
9929
9930    #[test]
9931    fn root_auth_writer_accepts_128_kib_authenticator_value() {
9932        let master_key = MasterKey::from_raw_key(&[8u8; 32]).unwrap();
9933        let authenticator_value = vec![0x5a; READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as usize];
9934        let expected_value = authenticator_value.clone();
9935        let archive = write_archive_with_root_auth(
9936            &[RegularFile::new("signed.txt", b"payload")],
9937            &master_key,
9938            single_volume_metadata_test_options(),
9939            RootAuthWriterConfig {
9940                authenticator_id: 0xcafe,
9941                signer_identity_type: 1,
9942                signer_identity: b"certificate-profile-signer",
9943                authenticator_value_length: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN,
9944            },
9945            |_| Ok(authenticator_value.clone()),
9946        )
9947        .unwrap();
9948
9949        let opened = open_archive(&archive.bytes, &master_key).unwrap();
9950        let footer = opened.root_auth_footer.as_ref().unwrap();
9951        assert_eq!(footer.authenticator_id, 0xcafe);
9952        assert_eq!(
9953            footer.authenticator_value.as_slice(),
9954            expected_value.as_slice()
9955        );
9956
9957        let verification = opened
9958            .verify_root_auth_with(|footer, _| {
9959                Ok(footer.authenticator_id == 0xcafe
9960                    && footer.authenticator_value.as_slice() == expected_value.as_slice())
9961            })
9962            .unwrap();
9963        assert_eq!(verification.authenticator_id, 0xcafe);
9964    }
9965
9966    #[test]
9967    fn streaming_writer_sink_round_trips_archive() {
9968        let files = [
9969            RegularFile::new("alpha.txt", b"alpha"),
9970            RegularFile::new("nested/beta.txt", b"beta payload"),
9971        ];
9972        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
9973        let mut sink = MemoryArchiveSink::default();
9974
9975        let summary = write_archive_sources_to_sink(
9976            &files,
9977            &master_key,
9978            single_volume_metadata_test_options(),
9979            None,
9980            &KdfParams::Raw,
9981            None,
9982            None,
9983            &mut sink,
9984        )
9985        .unwrap();
9986
9987        assert_eq!(summary.volume_count, 1);
9988        let opened = crate::reader::open_archive(&sink.volumes[0], &master_key).unwrap();
9989        assert_eq!(
9990            opened.extract_file("nested/beta.txt").unwrap(),
9991            Some(b"beta payload".to_vec())
9992        );
9993    }
9994
9995    #[test]
9996    fn ordered_sink_writer_round_trips_recipientwrap_records() {
9997        let files = [RegularFile::new("wrapped.txt", b"recipient sink payload")];
9998        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
9999        let mut sink = MemoryArchiveSink::default();
10000
10001        let summary = write_archive_sources_to_sink_ordered_parallel_with_recipient_wrap_records(
10002            &files,
10003            &master_key,
10004            single_volume_metadata_test_options(),
10005            vec![recipient_wrap_test_record()],
10006            None,
10007            None,
10008            &mut sink,
10009        )
10010        .unwrap();
10011
10012        assert_eq!(summary.volume_count, 1);
10013        let opened =
10014            open_archive_with_recipient_wrap_resolver(&sink.volumes[0], |_| Ok(vec![master_key.0]))
10015                .unwrap();
10016        assert_eq!(
10017            opened.extract_file("wrapped.txt").unwrap(),
10018            Some(b"recipient sink payload".to_vec())
10019        );
10020    }
10021
10022    #[test]
10023    fn ordered_sink_writer_indexes_v45_metadata_flags_from_member_semantics() {
10024        let mut sparse_payload = vec![0u8; TAR_BLOCK_LEN];
10025        sparse_payload[..2].copy_from_slice(b"0\n");
10026        let mut sparse_fork = NativeAuxiliaryMetadata::new(
10027            "macos.resource-fork",
10028            "macos-backup-v1",
10029            RestoreClass::SameOs,
10030            sparse_payload,
10031        );
10032        sparse_fork.flags = 1;
10033        sparse_fork.logical_size = 4096;
10034
10035        let sparse_metadata = PortableFileMetadata {
10036            source_os: "macos".into(),
10037            native: NativeFileMetadata {
10038                required_profiles: vec!["macos-backup-v1".into(), "posix-backup-v1".into()],
10039                auxiliary_records: vec![sparse_fork],
10040                ..NativeFileMetadata::default()
10041            },
10042            ..PortableFileMetadata::default()
10043        };
10044        let portable_only_metadata = PortableFileMetadata {
10045            native: NativeFileMetadata {
10046                required_profiles: vec![PORTABLE_PROFILE.into()],
10047                ..NativeFileMetadata::default()
10048            },
10049            ..PortableFileMetadata::default()
10050        };
10051        let files = [
10052            RegularFile {
10053                path: "sparse-fork.bin",
10054                contents: b"primary",
10055                mode: 0o644,
10056                mtime: ArchiveTimestamp::UNIX_EPOCH,
10057                portable_metadata: sparse_metadata,
10058            },
10059            RegularFile {
10060                path: "portable-only.bin",
10061                contents: b"portable",
10062                mode: 0o644,
10063                mtime: ArchiveTimestamp::UNIX_EPOCH,
10064                portable_metadata: portable_only_metadata,
10065            },
10066        ];
10067        let master_key = MasterKey::from_raw_key(&[0x45; 32]).unwrap();
10068        let mut sink = MemoryArchiveSink::default();
10069
10070        write_archive_sources_to_sink_ordered_parallel(
10071            &files,
10072            &master_key,
10073            single_volume_metadata_test_options(),
10074            &KdfParams::Raw,
10075            None,
10076            None,
10077            &mut sink,
10078        )
10079        .unwrap();
10080
10081        let opened = open_archive(&sink.volumes[0], &master_key).unwrap();
10082        opened.verify().unwrap();
10083        let entries = opened.list_index_entries().unwrap();
10084        let sparse = entries
10085            .iter()
10086            .find(|entry| entry.path == "sparse-fork.bin")
10087            .unwrap();
10088        assert_ne!(sparse.flags & HAS_SPARSE_EXTENTS, 0);
10089        let portable_only = entries
10090            .iter()
10091            .find(|entry| entry.path == "portable-only.bin")
10092            .unwrap();
10093        assert_eq!(portable_only.flags & HAS_NATIVE_METADATA, 0);
10094    }
10095
10096    #[test]
10097    fn single_pass_sink_writer_round_trips_recipientwrap_records() {
10098        let files = [RegularFile::new(
10099            "wrapped.txt",
10100            b"recipient single-pass sink payload",
10101        )];
10102        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
10103        let mut sink = MemoryArchiveSink::default();
10104
10105        let summary = write_archive_sources_to_sink_single_pass_with_recipient_wrap_records(
10106            &files,
10107            &master_key,
10108            single_volume_metadata_test_options(),
10109            vec![recipient_wrap_test_record()],
10110            None,
10111            None,
10112            &mut sink,
10113        )
10114        .unwrap();
10115
10116        assert_eq!(summary.volume_count, 1);
10117        let opened =
10118            open_archive_with_recipient_wrap_resolver(&sink.volumes[0], |_| Ok(vec![master_key.0]))
10119                .unwrap();
10120        assert_eq!(
10121            opened.extract_file("wrapped.txt").unwrap(),
10122            Some(b"recipient single-pass sink payload".to_vec())
10123        );
10124        opened.verify().unwrap();
10125    }
10126
10127    #[test]
10128    fn single_pass_sink_writer_round_trips_recipientwrap_root_auth() {
10129        let files = [RegularFile::new(
10130            "wrapped.txt",
10131            b"recipient single-pass signed payload",
10132        )];
10133        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
10134        let mut sink = MemoryArchiveSink::default();
10135
10136        let summary = write_archive_sources_to_sink_single_pass_with_recipient_wrap_records(
10137            &files,
10138            &master_key,
10139            single_volume_metadata_test_options(),
10140            vec![recipient_wrap_test_record()],
10141            Some(RootAuthWriterConfig {
10142                authenticator_id: 0x44,
10143                signer_identity_type: 1,
10144                signer_identity: b"recipient-wrap-single-pass",
10145                authenticator_value_length: 32,
10146            }),
10147            Some(&mut |request| Ok(request.archive_root.to_vec())),
10148            &mut sink,
10149        )
10150        .unwrap();
10151
10152        assert_eq!(summary.volume_count, 1);
10153        let opened =
10154            open_archive_with_recipient_wrap_resolver(&sink.volumes[0], |_| Ok(vec![master_key.0]))
10155                .unwrap();
10156        let verification = opened
10157            .verify_root_auth_with(|footer, archive_root| {
10158                Ok(footer.authenticator_id == 0x44
10159                    && footer.authenticator_value.as_slice() == archive_root)
10160            })
10161            .unwrap();
10162        assert_eq!(verification.volume_format_rev, VOLUME_FORMAT_REV_45);
10163    }
10164
10165    #[test]
10166    fn single_pass_writer_rejects_recipientwrap_kdf_without_records_before_writing() {
10167        let files = [RegularFile::new("wrapped.txt", b"recipient sink payload")];
10168        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
10169        let mut sink = MemoryArchiveSink::default();
10170        let kdf_params = KdfParams::RecipientWrap {
10171            key_wrap_table_length: 0,
10172            key_wrap_table_record_count: 1,
10173            key_wrap_table_version: 1,
10174            key_wrap_table_digest: [0u8; 32],
10175        };
10176
10177        let err = write_archive_sources_to_sink_single_pass(
10178            &files,
10179            &master_key,
10180            single_volume_metadata_test_options(),
10181            &kdf_params,
10182            None,
10183            None,
10184            &mut sink,
10185        )
10186        .unwrap_err();
10187
10188        match err {
10189            ArchiveWriteError::Format(FormatError::WriterUnsupported(message)) => {
10190                assert_eq!(message, "RecipientWrap requires key-wrap records");
10191            }
10192            other => panic!("unexpected error: {other:?}"),
10193        }
10194        assert!(sink.volumes.is_empty());
10195    }
10196
10197    #[test]
10198    fn ordered_sink_writer_rejects_recipientwrap_kdf_without_records_before_writing() {
10199        let files = [RegularFile::new("wrapped.txt", b"recipient sink payload")];
10200        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
10201        let mut sink = MemoryArchiveSink::default();
10202        let kdf_params = KdfParams::RecipientWrap {
10203            key_wrap_table_length: 0,
10204            key_wrap_table_record_count: 1,
10205            key_wrap_table_version: 1,
10206            key_wrap_table_digest: [0u8; 32],
10207        };
10208
10209        let err = write_archive_sources_to_sink_ordered_parallel(
10210            &files,
10211            &master_key,
10212            single_volume_metadata_test_options(),
10213            &kdf_params,
10214            None,
10215            None,
10216            &mut sink,
10217        )
10218        .unwrap_err();
10219
10220        match err {
10221            ArchiveWriteError::Format(FormatError::WriterUnsupported(message)) => {
10222                assert_eq!(message, "RecipientWrap requires key-wrap records");
10223            }
10224            other => panic!("unexpected error: {other:?}"),
10225        }
10226        assert!(sink.volumes.is_empty());
10227    }
10228
10229    #[test]
10230    fn streaming_writer_bounds_source_reads_and_sink_writes_for_large_file() {
10231        let file_size = 3 * 1024 * 1024;
10232        let stats = Rc::new(RefCell::new(GeneratedSourceStats::default()));
10233        let file = GeneratedFileSource {
10234            path: "large/generated.bin",
10235            len: file_size,
10236            stats: Rc::clone(&stats),
10237        };
10238        let master_key = MasterKey::from_raw_key(&[3u8; 32]).unwrap();
10239        let options = plan_writer_options(WriterOptions {
10240            block_size: MIN_BLOCK_SIZE,
10241            chunk_size: 16 * 1024,
10242            envelope_target_size: 64 * 1024,
10243            stripe_width: 1,
10244            volume_loss_tolerance: 0,
10245            bit_rot_buffer_pct: 0,
10246            fec_parity_shards: 0,
10247            index_fec_parity_shards: 0,
10248            index_root_fec_parity_shards: 0,
10249            ..WriterOptions::default()
10250        })
10251        .unwrap();
10252        let mut sink = TrackingArchiveSink::default();
10253
10254        let summary = write_archive_sources_to_sink_single_pass(
10255            &[file],
10256            &master_key,
10257            options,
10258            &KdfParams::Raw,
10259            None,
10260            None,
10261            &mut sink,
10262        )
10263        .unwrap();
10264
10265        let stats = stats.borrow();
10266        assert_eq!(stats.open_count, 1);
10267        assert_eq!(stats.total_read, file_size as u64);
10268        assert!(stats.max_read_request <= options.chunk_size as usize);
10269        assert_eq!(summary.volume_count, 1);
10270        assert_eq!(summary.archive_bytes, sink.volume_bytes.iter().sum());
10271        assert_eq!(
10272            summary.bootstrap_sidecar_bytes,
10273            sink.bootstrap_sidecar_bytes
10274        );
10275        assert!(sink.max_write_len <= 128 * 1024);
10276    }
10277
10278    #[test]
10279    fn sink_writer_progress_reports_source_bytes_for_each_multi_pass_phase() {
10280        let file_size = 512 * 1024;
10281        let stats = Rc::new(RefCell::new(GeneratedSourceStats::default()));
10282        let file = GeneratedFileSource {
10283            path: "large/generated.bin",
10284            len: file_size,
10285            stats: Rc::clone(&stats),
10286        };
10287        let master_key = MasterKey::from_raw_key(&[4u8; 32]).unwrap();
10288        let options = WriterOptions {
10289            block_size: MIN_BLOCK_SIZE,
10290            chunk_size: 16 * 1024,
10291            envelope_target_size: 64 * 1024,
10292            stripe_width: 1,
10293            volume_loss_tolerance: 0,
10294            bit_rot_buffer_pct: 0,
10295            fec_parity_shards: 0,
10296            index_fec_parity_shards: 0,
10297            index_root_fec_parity_shards: 0,
10298            ..WriterOptions::default()
10299        };
10300        let mut sink = MemoryArchiveSink::default();
10301        let mut progress = RecordingWriteProgress::default();
10302
10303        let summary = write_archive_sources_to_sink_with_progress(
10304            &[file],
10305            &master_key,
10306            options,
10307            None,
10308            &KdfParams::Raw,
10309            None,
10310            None,
10311            &mut sink,
10312            &mut progress,
10313        )
10314        .unwrap();
10315
10316        let stats = stats.borrow();
10317        assert!(stats.open_count > 1);
10318        assert_eq!(
10319            progress.bytes_for(ArchiveWritePhase::PlanningPayload),
10320            file_size as u64
10321        );
10322        assert_eq!(
10323            progress.bytes_for(ArchiveWritePhase::EmittingPayload),
10324            file_size as u64
10325        );
10326        assert_eq!(summary.volume_count, 1);
10327        assert!(!sink.volumes.is_empty());
10328    }
10329
10330    #[test]
10331    fn sink_writer_progress_reports_multi_pass_phases_and_phase_bytes() {
10332        let file_size = 512 * 1024;
10333        let stats = Rc::new(RefCell::new(GeneratedSourceStats::default()));
10334        let file = GeneratedFileSource {
10335            path: "large/generated.bin",
10336            len: file_size,
10337            stats: Rc::clone(&stats),
10338        };
10339        let master_key = MasterKey::from_raw_key(&[4u8; 32]).unwrap();
10340        let options = WriterOptions {
10341            block_size: MIN_BLOCK_SIZE,
10342            chunk_size: 16 * 1024,
10343            envelope_target_size: 64 * 1024,
10344            stripe_width: 1,
10345            volume_loss_tolerance: 0,
10346            bit_rot_buffer_pct: 0,
10347            fec_parity_shards: 0,
10348            index_fec_parity_shards: 0,
10349            index_root_fec_parity_shards: 0,
10350            ..WriterOptions::default()
10351        };
10352        let mut sink = MemoryArchiveSink::default();
10353        let mut progress = RecordingWriteProgress::default();
10354
10355        write_archive_sources_to_sink_with_progress(
10356            &[file],
10357            &master_key,
10358            options,
10359            None,
10360            &KdfParams::Raw,
10361            None,
10362            None,
10363            &mut sink,
10364            &mut progress,
10365        )
10366        .unwrap();
10367
10368        assert_eq!(
10369            progress.phases,
10370            vec![
10371                ArchiveWritePhase::PlanningPayload,
10372                ArchiveWritePhase::PlanningMetadata,
10373                ArchiveWritePhase::EmittingPayload,
10374                ArchiveWritePhase::EmittingMetadata,
10375            ]
10376        );
10377        assert_eq!(
10378            progress.bytes_for(ArchiveWritePhase::PlanningPayload),
10379            file_size as u64
10380        );
10381        assert_eq!(
10382            progress.bytes_for(ArchiveWritePhase::EmittingPayload),
10383            file_size as u64
10384        );
10385    }
10386
10387    #[test]
10388    fn sink_writer_progress_reports_each_volume_size_replanning_attempt() {
10389        let file_size = 64 * 1024;
10390        let mut contents = Vec::with_capacity(file_size);
10391        let mut random = 0x1234_5678u32;
10392        for _ in 0..file_size {
10393            random = random.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
10394            contents.push((random >> 24) as u8);
10395        }
10396        let file = RegularFile::new("sized.bin", &contents);
10397        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
10398        let options = WriterOptions {
10399            block_size: 4 * 1024,
10400            chunk_size: 4 * 1024,
10401            envelope_target_size: 128 * 1024,
10402            stripe_width: 1,
10403            volume_loss_tolerance: 1,
10404            target_volume_size: Some(8 * 1024),
10405            ..WriterOptions::default()
10406        };
10407        let mut sink = MemoryArchiveSink::default();
10408        let mut progress = RecordingWriteProgress::default();
10409
10410        write_archive_sources_to_sink_with_progress(
10411            &[file],
10412            &master_key,
10413            options,
10414            None,
10415            &KdfParams::Raw,
10416            None,
10417            None,
10418            &mut sink,
10419            &mut progress,
10420        )
10421        .unwrap();
10422
10423        let planning_attempts = progress
10424            .phases
10425            .iter()
10426            .filter(|phase| **phase == ArchiveWritePhase::PlanningPayload)
10427            .count();
10428        assert!(planning_attempts > 1);
10429        assert_eq!(
10430            progress.phases.len(),
10431            planning_attempts * 2 + 2,
10432            "each planning attempt has payload and metadata phases before emission",
10433        );
10434        for phases in progress.phases[..planning_attempts * 2].chunks_exact(2) {
10435            assert_eq!(
10436                phases,
10437                [
10438                    ArchiveWritePhase::PlanningPayload,
10439                    ArchiveWritePhase::PlanningMetadata,
10440                ]
10441            );
10442        }
10443        assert_eq!(
10444            &progress.phases[planning_attempts * 2..],
10445            [
10446                ArchiveWritePhase::EmittingPayload,
10447                ArchiveWritePhase::EmittingMetadata,
10448            ]
10449        );
10450        assert_eq!(
10451            progress.bytes_for(ArchiveWritePhase::PlanningPayload),
10452            file_size as u64 * planning_attempts as u64,
10453        );
10454        assert_eq!(
10455            progress.bytes_for(ArchiveWritePhase::EmittingPayload),
10456            file_size as u64,
10457        );
10458    }
10459
10460    #[test]
10461    fn single_pass_progress_reports_emission_phases() {
10462        let file_size = 128 * 1024;
10463        let stats = Rc::new(RefCell::new(GeneratedSourceStats::default()));
10464        let file = GeneratedFileSource {
10465            path: "single/generated.bin",
10466            len: file_size,
10467            stats,
10468        };
10469        let master_key = MasterKey::from_raw_key(&[5u8; 32]).unwrap();
10470        let options = progress_test_writer_options();
10471        let mut sink = MemoryArchiveSink::default();
10472        let mut progress = RecordingWriteProgress::default();
10473
10474        write_archive_sources_to_sink_single_pass_with_progress(
10475            &[file],
10476            &master_key,
10477            options,
10478            &KdfParams::Raw,
10479            None,
10480            None,
10481            &mut sink,
10482            &mut progress,
10483        )
10484        .unwrap();
10485
10486        assert_eq!(
10487            progress.phases,
10488            vec![
10489                ArchiveWritePhase::EmittingPayload,
10490                ArchiveWritePhase::EmittingMetadata,
10491            ]
10492        );
10493        assert_eq!(
10494            progress.bytes_for(ArchiveWritePhase::EmittingPayload),
10495            file_size as u64
10496        );
10497    }
10498
10499    #[test]
10500    fn ordered_parallel_progress_reports_emission_phases() {
10501        let file_size = 128 * 1024;
10502        let stats = Rc::new(RefCell::new(GeneratedSourceStats::default()));
10503        let file = GeneratedFileSource {
10504            path: "parallel/generated.bin",
10505            len: file_size,
10506            stats,
10507        };
10508        let master_key = MasterKey::from_raw_key(&[6u8; 32]).unwrap();
10509        let options = progress_test_writer_options();
10510        let mut sink = MemoryArchiveSink::default();
10511        let mut progress = RecordingWriteProgress::default();
10512
10513        write_archive_sources_to_sink_ordered_parallel_with_progress(
10514            &[file],
10515            &master_key,
10516            options,
10517            &KdfParams::Raw,
10518            None,
10519            None,
10520            &mut sink,
10521            &mut progress,
10522        )
10523        .unwrap();
10524
10525        assert_eq!(
10526            progress.phases,
10527            vec![
10528                ArchiveWritePhase::EmittingPayload,
10529                ArchiveWritePhase::EmittingMetadata,
10530            ]
10531        );
10532        assert_eq!(
10533            progress.bytes_for(ArchiveWritePhase::EmittingPayload),
10534            file_size as u64
10535        );
10536    }
10537
10538    fn progress_test_writer_options() -> WriterOptions {
10539        WriterOptions {
10540            block_size: MIN_BLOCK_SIZE,
10541            chunk_size: 16 * 1024,
10542            envelope_target_size: 64 * 1024,
10543            stripe_width: 1,
10544            volume_loss_tolerance: 0,
10545            bit_rot_buffer_pct: 0,
10546            fec_parity_shards: 0,
10547            index_fec_parity_shards: 0,
10548            index_root_fec_parity_shards: 0,
10549            ..WriterOptions::default()
10550        }
10551    }
10552
10553    #[derive(Default)]
10554    struct RecordingWriteProgress {
10555        phases: Vec<ArchiveWritePhase>,
10556        phase_bytes: BTreeMap<ArchiveWritePhase, u64>,
10557    }
10558
10559    impl RecordingWriteProgress {
10560        fn bytes_for(&self, phase: ArchiveWritePhase) -> u64 {
10561            self.phase_bytes.get(&phase).copied().unwrap_or_default()
10562        }
10563    }
10564
10565    impl ArchiveWriteProgressSink for RecordingWriteProgress {
10566        fn phase_started(&mut self, phase: ArchiveWritePhase) {
10567            self.phases.push(phase);
10568        }
10569
10570        fn source_bytes_read(&mut self, phase: ArchiveWritePhase, _archive_path: &str, bytes: u64) {
10571            let total = self.phase_bytes.entry(phase).or_default();
10572            *total = total.saturating_add(bytes);
10573        }
10574    }
10575
10576    #[derive(Default)]
10577    struct GeneratedSourceStats {
10578        open_count: usize,
10579        total_read: u64,
10580        max_read_request: usize,
10581    }
10582
10583    struct GeneratedFileSource {
10584        path: &'static str,
10585        len: usize,
10586        stats: Rc<RefCell<GeneratedSourceStats>>,
10587    }
10588
10589    impl RegularFileSource for GeneratedFileSource {
10590        fn archive_path(&self) -> &str {
10591            self.path
10592        }
10593
10594        fn file_data_size(&self) -> u64 {
10595            self.len as u64
10596        }
10597
10598        fn mode(&self) -> u32 {
10599            0o644
10600        }
10601
10602        fn mtime(&self) -> ArchiveTimestamp {
10603            ArchiveTimestamp::UNIX_EPOCH
10604        }
10605
10606        fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
10607            self.stats.borrow_mut().open_count += 1;
10608            Ok(Box::new(GeneratedReader {
10609                remaining: self.len,
10610                position: 0,
10611                stats: Rc::clone(&self.stats),
10612            }))
10613        }
10614    }
10615
10616    struct GeneratedReader {
10617        remaining: usize,
10618        position: usize,
10619        stats: Rc<RefCell<GeneratedSourceStats>>,
10620    }
10621
10622    impl Read for GeneratedReader {
10623        fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
10624            if self.remaining == 0 {
10625                return Ok(0);
10626            }
10627            let count = out.len().min(self.remaining);
10628            for (offset, byte) in out[..count].iter_mut().enumerate() {
10629                let position = self.position + offset;
10630                *byte = position.wrapping_mul(31).wrapping_add(17) as u8;
10631            }
10632            self.position += count;
10633            self.remaining -= count;
10634            let mut stats = self.stats.borrow_mut();
10635            stats.total_read += count as u64;
10636            stats.max_read_request = stats.max_read_request.max(out.len());
10637            Ok(count)
10638        }
10639    }
10640
10641    #[derive(Default)]
10642    struct TrackingArchiveSink {
10643        volume_bytes: Vec<u64>,
10644        bootstrap_sidecar_bytes: u64,
10645        max_write_len: usize,
10646    }
10647
10648    impl ArchiveWriteSink for TrackingArchiveSink {
10649        fn begin_archive(&mut self, volume_count: usize) -> Result<(), ArchiveWriteError> {
10650            self.volume_bytes = vec![0; volume_count];
10651            self.bootstrap_sidecar_bytes = 0;
10652            self.max_write_len = 0;
10653            Ok(())
10654        }
10655
10656        fn write_volume(
10657            &mut self,
10658            volume_index: usize,
10659            bytes: &[u8],
10660        ) -> Result<(), ArchiveWriteError> {
10661            let volume =
10662                self.volume_bytes
10663                    .get_mut(volume_index)
10664                    .ok_or(FormatError::WriterInvariant(
10665                        "tracking sink volume index is out of bounds",
10666                    ))?;
10667            *volume += bytes.len() as u64;
10668            self.max_write_len = self.max_write_len.max(bytes.len());
10669            Ok(())
10670        }
10671
10672        fn write_bootstrap_sidecar(&mut self, bytes: &[u8]) -> Result<(), ArchiveWriteError> {
10673            self.bootstrap_sidecar_bytes += bytes.len() as u64;
10674            self.max_write_len = self.max_write_len.max(bytes.len());
10675            Ok(())
10676        }
10677    }
10678
10679    fn deterministic_bytes(len: usize) -> Vec<u8> {
10680        let mut state = 0x4d41_4d45u32;
10681        let mut out = Vec::with_capacity(len);
10682        for _ in 0..len {
10683            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
10684            out.push((state >> 24) as u8);
10685        }
10686        out
10687    }
10688
10689    fn single_volume_metadata_test_options() -> WriterOptions {
10690        plan_writer_options(WriterOptions {
10691            block_size: MIN_BLOCK_SIZE,
10692            stripe_width: 1,
10693            volume_loss_tolerance: 0,
10694            bit_rot_buffer_pct: 0,
10695            index_root_fec_parity_shards: 0,
10696            ..WriterOptions::default()
10697        })
10698        .unwrap()
10699    }
10700
10701    fn payload_len_for_encrypted_data_blocks(
10702        data_block_count: u32,
10703        options: WriterOptions,
10704    ) -> usize {
10705        assert!(data_block_count > 0);
10706        if data_block_count == 1 {
10707            return 1;
10708        }
10709        let block_size = options.block_size as usize;
10710        (data_block_count as usize - 1) * block_size - options.aead_algo.tag_len() + 1
10711    }
10712
10713    fn assert_path_specific_member_group(group: &[u8]) {
10714        let mut cursor = 0usize;
10715        let mut saw_main = false;
10716        while cursor < group.len() {
10717            let header = &group[cursor..cursor + TAR_BLOCK_LEN];
10718            assert!(
10719                header.iter().any(|byte| *byte != 0),
10720                "writer emitted tar zero block inside member group"
10721            );
10722            let typeflag = header[156];
10723            assert_ne!(typeflag, b'g', "writer emitted global PAX metadata");
10724            assert!(
10725                !matches!(typeflag, b'V' | b'M' | b'N'),
10726                "writer emitted global GNU metadata"
10727            );
10728            assert!(
10729                matches!(typeflag, b'x' | b'0'),
10730                "writer emitted unexpected tar record type {typeflag:?}"
10731            );
10732            if typeflag == b'0' {
10733                saw_main = true;
10734            }
10735            let size = read_test_tar_octal(&header[124..136]);
10736            cursor += TAR_BLOCK_LEN + size + padding_to_512(size);
10737        }
10738        assert_eq!(cursor, group.len());
10739        assert!(saw_main);
10740    }
10741
10742    fn read_test_tar_octal(field: &[u8]) -> usize {
10743        let mut value = 0usize;
10744        for byte in field {
10745            match *byte {
10746                0 | b' ' => break,
10747                b'0'..=b'7' => {
10748                    value = value * 8 + usize::from(*byte - b'0');
10749                }
10750                other => panic!("malformed test tar octal byte {other:?}"),
10751            }
10752        }
10753        value
10754    }
10755
10756    fn test_file_row(idx: usize, path_hash: [u8; 8]) -> FileRow {
10757        let path = format!("file-{idx:05}.txt").into_bytes();
10758        FileRow {
10759            path_hash,
10760            path: path.clone(),
10761            member_index: idx,
10762            member: TarMember {
10763                path,
10764                entry_kind: SourceEntryKind::Regular,
10765                link_target: None,
10766                tar_member_group_start: idx as u64 * 512,
10767                tar_member_group_size: 512,
10768                file_data_size: 0,
10769                sparse_extents: None,
10770                mode: 0o644,
10771                mtime: ArchiveTimestamp::UNIX_EPOCH,
10772                portable_metadata: PortableFileMetadata::default(),
10773            },
10774        }
10775    }
10776}