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::time::{Duration, Instant};
6
7use sha2::{Digest, Sha256};
8use uuid::Uuid;
9
10use crate::compression::{
11    compress_zstd_frame_with_dictionary_and_jobs, compress_zstd_frame_with_jobs,
12};
13use crate::crypto::{
14    aead_encrypt, build_aad, compute_integrity_tag, derive_nonce, HmacDomain, KdfParams, MasterKey,
15    Subkeys,
16};
17use crate::fec::encode_parity_gf16;
18use crate::format::{
19    AeadAlgo, ArchiveWriteError, BlockKind, CompressionAlgo, FecAlgo, FormatError, KdfAlgo,
20    BLOCK_RECORD_FRAMING_LEN, BOOTSTRAP_SIDECAR_HEADER_LEN, CRITICAL_RECOVERY_LOCATOR_LEN,
21    CRYPTO_EXTENSION_HEADER_LEN, CRYPTO_HEADER_FIXED_LEN, CRYPTO_HEADER_HMAC_LEN, FORMAT_VERSION,
22    MANIFEST_FOOTER_LEN, READER_MAX_CMRA_PARITY_PCT, READER_MAX_INDEX_ROOT_FEC_CLASS_SHARDS,
23    READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN, READER_MAX_ROOT_AUTH_FOOTER_LEN,
24    READER_MAX_ROOT_AUTH_SIGNER_IDENTITY_LEN, VOLUME_FORMAT_REV, VOLUME_HEADER_LEN,
25    VOLUME_TRAILER_LEN,
26};
27use crate::metadata::{
28    hash_prefix, normalize_lookup_file_path, validate_file_path_bytes, DirectoryHintEntry,
29    DirectoryHintShardEntry, DirectoryHintTableHeader, EnvelopeEntry, FileEntry, FrameEntry,
30    IndexRoot, IndexRootHeader, IndexShardHeader, ShardEntry, DIRECTORY_HINT_ENTRY_LEN,
31    DIRECTORY_HINT_TABLE_LEN, ENVELOPE_ENTRY_LEN, FILE_ENTRY_LEN, FRAME_ENTRY_LEN,
32    INDEX_SHARD_HEADER_LEN,
33};
34use crate::padding::suffix_pad_for_aead;
35use crate::root_auth::{
36    archive_root, critical_metadata_digest, data_block_merkle_leaf_hash,
37    data_block_merkle_root_from_leaf_hashes, fec_layout_digest, index_digest,
38    root_auth_descriptor_digest, signer_identity_digest, ArchiveRootInputs,
39    CriticalMetadataDigestInputs, FecLayoutObjectRow,
40};
41use crate::wire::{
42    BlockRecord, BootstrapSidecarHeader, CriticalMetadataImage, CriticalMetadataRecoveryHeader,
43    CriticalMetadataRecoveryShard, CriticalRecoveryLocator, CryptoHeader, CryptoHeaderFixed,
44    ManifestFooter, RootAuthFooterV1, SerializedRegion, VolumeHeader, VolumeTrailer,
45};
46
47const TAR_BLOCK_LEN: usize = 512;
48const MAX_REED_SOLOMON_GF16_SHARDS: u64 = 65_535;
49const MIN_BLOCK_SIZE: u32 = 4096;
50const DEFAULT_BLOCK_SIZE: u32 = 64 * 1024;
51const DEFAULT_CHUNK_SIZE: u32 = 256 * 1024;
52const DEFAULT_ENVELOPE_TARGET_SIZE: u32 = 1024 * 1024;
53const DEFAULT_FEC_DATA_SHARDS: u16 = 224;
54const DEFAULT_FEC_PARITY_SHARDS: u16 = 1;
55const DEFAULT_INDEX_FEC_DATA_SHARDS: u16 = 16;
56const DEFAULT_INDEX_FEC_PARITY_SHARDS: u16 = 1;
57const MIN_INDEX_ROOT_FEC_DATA_SHARDS: u16 = 16;
58const DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS: u16 = MIN_INDEX_ROOT_FEC_DATA_SHARDS;
59const DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS: u16 = 1;
60const DEFAULT_STRIPE_WIDTH: u32 = 8;
61const DEFAULT_VOLUME_LOSS_TOLERANCE: u8 = 1;
62const DEFAULT_BIT_ROT_BUFFER_PCT: u8 = 5;
63const DEFAULT_FILES_PER_INDEX_SHARD: usize = 10_000;
64const DIRECTORY_HINT_REQUIRED_FILE_COUNT: usize = 100_000;
65const MAX_FILES_PER_INDEX_SHARD: usize = 1_000_000;
66const MAX_HASH_PREFIX_RUN_FILES: usize = 50_000;
67const DEFAULT_DIRECTORY_HINT_ENTRIES_PER_SHARD: usize = 10_000;
68const CMRA_SHARD_SIZE: usize = 512;
69
70fn default_jobs() -> usize {
71    std::thread::available_parallelism()
72        .map(|jobs| jobs.get())
73        .unwrap_or(1)
74}
75
76fn should_emit_directory_hints(file_count: usize) -> bool {
77    file_count > DIRECTORY_HINT_REQUIRED_FILE_COUNT
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct WriterOptions {
82    pub block_size: u32,
83    pub chunk_size: u32,
84    pub envelope_target_size: u32,
85    pub stripe_width: u32,
86    pub volume_loss_tolerance: u8,
87    pub bit_rot_buffer_pct: u8,
88    pub zstd_level: i32,
89    pub jobs: usize,
90    pub aead_algo: AeadAlgo,
91    pub fec_data_shards: u16,
92    pub fec_parity_shards: u16,
93    pub index_fec_data_shards: u16,
94    pub index_fec_parity_shards: u16,
95    pub index_root_fec_data_shards: u16,
96    pub index_root_fec_parity_shards: u16,
97    pub max_path_length: u32,
98    pub target_volume_size: Option<u64>,
99    pub archive_uuid: Option<[u8; 16]>,
100    pub session_id: Option<[u8; 16]>,
101    pub closed_at_ns: i64,
102}
103
104impl Default for WriterOptions {
105    fn default() -> Self {
106        Self {
107            block_size: DEFAULT_BLOCK_SIZE,
108            chunk_size: DEFAULT_CHUNK_SIZE,
109            envelope_target_size: DEFAULT_ENVELOPE_TARGET_SIZE,
110            stripe_width: DEFAULT_STRIPE_WIDTH,
111            volume_loss_tolerance: DEFAULT_VOLUME_LOSS_TOLERANCE,
112            bit_rot_buffer_pct: DEFAULT_BIT_ROT_BUFFER_PCT,
113            zstd_level: 3,
114            jobs: default_jobs(),
115            aead_algo: AeadAlgo::AesGcmSiv256,
116            fec_data_shards: DEFAULT_FEC_DATA_SHARDS,
117            fec_parity_shards: DEFAULT_FEC_PARITY_SHARDS,
118            index_fec_data_shards: DEFAULT_INDEX_FEC_DATA_SHARDS,
119            index_fec_parity_shards: DEFAULT_INDEX_FEC_PARITY_SHARDS,
120            index_root_fec_data_shards: DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS,
121            index_root_fec_parity_shards: DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS,
122            max_path_length: 4096,
123            target_volume_size: None,
124            archive_uuid: None,
125            session_id: None,
126            closed_at_ns: 0,
127        }
128    }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub struct RootAuthWriterConfig<'a> {
133    pub authenticator_id: u16,
134    pub signer_identity_type: u16,
135    pub signer_identity: &'a [u8],
136    pub authenticator_value_length: u32,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct RootAuthSigningRequest {
141    pub archive_uuid: [u8; 16],
142    pub session_id: [u8; 16],
143    pub archive_root: [u8; 32],
144}
145
146pub type RootAuthAuthenticator<'a> =
147    dyn FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError> + 'a;
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct RegularFile<'a> {
151    pub path: &'a str,
152    pub contents: &'a [u8],
153    pub mode: u32,
154    pub mtime: u64,
155}
156
157impl<'a> RegularFile<'a> {
158    pub fn new(path: &'a str, contents: &'a [u8]) -> Self {
159        Self {
160            path,
161            contents,
162            mode: 0o644,
163            mtime: 0,
164        }
165    }
166}
167
168/// Re-openable source for one regular file written into an archive.
169///
170/// The writer may replan when options such as target volume sizing need another
171/// pass, so implementations must return a fresh reader from each `open` call.
172pub trait RegularFileSource {
173    fn archive_path(&self) -> &str;
174    fn file_data_size(&self) -> u64;
175    fn mode(&self) -> u32;
176    fn mtime(&self) -> u64;
177    fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError>;
178}
179
180impl RegularFileSource for RegularFile<'_> {
181    fn archive_path(&self) -> &str {
182        self.path
183    }
184
185    fn file_data_size(&self) -> u64 {
186        self.contents.len() as u64
187    }
188
189    fn mode(&self) -> u32 {
190        self.mode
191    }
192
193    fn mtime(&self) -> u64 {
194        self.mtime
195    }
196
197    fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
198        Ok(Box::new(Cursor::new(self.contents)))
199    }
200}
201
202/// Receives source-byte progress while the archive writer consumes file data.
203///
204/// The writer may open source files multiple times when planning or replanning
205/// an archive. Progress callbacks report each archive member's logical source
206/// bytes at most once, capped by [`RegularFileSource::file_data_size`].
207pub trait ArchiveWriteProgressSink {
208    /// Reports newly consumed source bytes for one archive member.
209    fn source_bytes_read(&mut self, archive_path: &str, bytes: u64);
210}
211
212impl<F> ArchiveWriteProgressSink for F
213where
214    F: FnMut(&str, u64),
215{
216    fn source_bytes_read(&mut self, archive_path: &str, bytes: u64) {
217        self(archive_path, bytes);
218    }
219}
220
221struct SourceProgressState<'a> {
222    sink: &'a mut dyn ArchiveWriteProgressSink,
223    reported_by_path: BTreeMap<String, u64>,
224}
225
226impl<'a> SourceProgressState<'a> {
227    fn new(sink: &'a mut dyn ArchiveWriteProgressSink) -> Self {
228        Self {
229            sink,
230            reported_by_path: BTreeMap::new(),
231        }
232    }
233
234    fn record(&mut self, archive_path: &str, bytes: u64, file_data_size: u64) {
235        if bytes == 0 || file_data_size == 0 {
236            return;
237        }
238        let reported = self
239            .reported_by_path
240            .entry(archive_path.to_owned())
241            .or_default();
242        let capped_next = reported.saturating_add(bytes).min(file_data_size);
243        let delta = capped_next.saturating_sub(*reported);
244        if delta == 0 {
245            return;
246        }
247        *reported = capped_next;
248        self.sink.source_bytes_read(archive_path, delta);
249    }
250}
251
252struct ProgressRegularFileSource<'a, S> {
253    inner: &'a S,
254    state: Rc<RefCell<SourceProgressState<'a>>>,
255}
256
257impl<S: RegularFileSource> RegularFileSource for ProgressRegularFileSource<'_, S> {
258    fn archive_path(&self) -> &str {
259        self.inner.archive_path()
260    }
261
262    fn file_data_size(&self) -> u64 {
263        self.inner.file_data_size()
264    }
265
266    fn mode(&self) -> u32 {
267        self.inner.mode()
268    }
269
270    fn mtime(&self) -> u64 {
271        self.inner.mtime()
272    }
273
274    fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
275        Ok(Box::new(SourceProgressReader {
276            inner: self.inner.open()?,
277            archive_path: self.inner.archive_path().to_owned(),
278            file_data_size: self.inner.file_data_size(),
279            state: Rc::clone(&self.state),
280        }))
281    }
282}
283
284struct SourceProgressReader<'a> {
285    inner: Box<dyn Read + 'a>,
286    archive_path: String,
287    file_data_size: u64,
288    state: Rc<RefCell<SourceProgressState<'a>>>,
289}
290
291impl Read for SourceProgressReader<'_> {
292    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
293        let bytes = self.inner.read(buf)?;
294        self.state
295            .borrow_mut()
296            .record(&self.archive_path, bytes as u64, self.file_data_size);
297        Ok(bytes)
298    }
299}
300
301fn progress_sources<'a, S: RegularFileSource>(
302    files: &'a [S],
303    sink: &'a mut dyn ArchiveWriteProgressSink,
304) -> Vec<ProgressRegularFileSource<'a, S>> {
305    let state = Rc::new(RefCell::new(SourceProgressState::new(sink)));
306    files
307        .iter()
308        .map(|inner| ProgressRegularFileSource {
309            inner,
310            state: Rc::clone(&state),
311        })
312        .collect()
313}
314
315/// Streaming destination for archive volumes and optional bootstrap sidecar.
316///
317/// Calls arrive in archive order for each volume, but records are interleaved
318/// across volumes according to the archive stripe layout.
319pub trait ArchiveWriteSink {
320    fn begin_archive(&mut self, volume_count: usize) -> Result<(), ArchiveWriteError>;
321    fn write_volume(&mut self, volume_index: usize, bytes: &[u8]) -> Result<(), ArchiveWriteError>;
322    fn write_bootstrap_sidecar(&mut self, bytes: &[u8]) -> Result<(), ArchiveWriteError>;
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct WrittenArchiveSummary {
327    pub volume_count: usize,
328    pub archive_bytes: u64,
329    pub bootstrap_sidecar_bytes: u64,
330    pub archive_uuid: [u8; 16],
331    pub session_id: [u8; 16],
332    pub timings: WriterTimings,
333}
334
335#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
336pub struct WriterTimings {
337    pub total: Duration,
338    pub plan_payload: Duration,
339    pub plan_metadata: Duration,
340    pub emit_payload: Duration,
341    pub emit_metadata: Duration,
342}
343
344impl WriterTimings {
345    fn add_assign(&mut self, other: Self) {
346        self.total += other.total;
347        self.plan_payload += other.plan_payload;
348        self.plan_metadata += other.plan_metadata;
349        self.emit_payload += other.emit_payload;
350        self.emit_metadata += other.emit_metadata;
351    }
352}
353
354/// In-memory sink used by the compatibility writer APIs.
355#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct MemoryArchiveSink {
357    pub volumes: Vec<Vec<u8>>,
358    pub bootstrap_sidecar: Vec<u8>,
359}
360
361impl ArchiveWriteSink for MemoryArchiveSink {
362    fn begin_archive(&mut self, volume_count: usize) -> Result<(), ArchiveWriteError> {
363        self.volumes = vec![Vec::new(); volume_count];
364        self.bootstrap_sidecar.clear();
365        Ok(())
366    }
367
368    fn write_volume(&mut self, volume_index: usize, bytes: &[u8]) -> Result<(), ArchiveWriteError> {
369        let volume = self
370            .volumes
371            .get_mut(volume_index)
372            .ok_or(FormatError::WriterInvariant(
373                "volume sink index is out of bounds",
374            ))?;
375        volume.extend_from_slice(bytes);
376        Ok(())
377    }
378
379    fn write_bootstrap_sidecar(&mut self, bytes: &[u8]) -> Result<(), ArchiveWriteError> {
380        self.bootstrap_sidecar.extend_from_slice(bytes);
381        Ok(())
382    }
383}
384
385/// Completed archive artifacts produced by the compatibility writer APIs.
386///
387/// APIs returning this value build all volume bytes before returning. Use the
388/// sink writer when archive bytes should be delivered incrementally.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct WrittenArchive {
391    pub bytes: Vec<u8>,
392    pub volumes: Vec<Vec<u8>>,
393    pub bootstrap_sidecar: Vec<u8>,
394    pub archive_uuid: [u8; 16],
395    pub session_id: [u8; 16],
396    pub timings: WriterTimings,
397}
398
399#[derive(Debug, Clone)]
400struct TarMember {
401    path: Vec<u8>,
402    tar_member_group_start: u64,
403    tar_member_group_size: u64,
404    file_data_size: u64,
405    mode: u32,
406    mtime: u64,
407}
408
409#[derive(Debug, Clone)]
410struct PayloadFrame {
411    frame_index: u64,
412    envelope_index: u64,
413    member_index: usize,
414    offset_in_envelope: u32,
415    compressed_size: u32,
416    decompressed_size: u32,
417    flags: u32,
418    tar_stream_offset: u64,
419}
420
421#[derive(Debug, Clone)]
422struct FileRow {
423    path_hash: [u8; 8],
424    path: Vec<u8>,
425    member_index: usize,
426    member: TarMember,
427}
428
429#[derive(Debug, Clone)]
430struct PlannedIndexShard {
431    shard_index: u64,
432    plaintext: Vec<u8>,
433    file_count: u32,
434    first_path_hash: [u8; 8],
435    last_path_hash: [u8; 8],
436}
437
438#[derive(Debug, Clone)]
439struct PlannedDirectoryHintShard {
440    hint_shard_index: u64,
441    plaintext: Vec<u8>,
442    entry_count: u64,
443    first_dir_hash: [u8; 8],
444    last_dir_hash: [u8; 8],
445}
446
447#[derive(Debug, Clone)]
448#[cfg(test)]
449struct PayloadEnvelope {
450    envelope_index: u64,
451    plaintext: Vec<u8>,
452}
453
454#[derive(Debug, Clone)]
455struct PayloadObject {
456    envelope_index: u64,
457    plaintext_size: u32,
458    object: ObjectExtent,
459}
460
461#[derive(Debug, Clone)]
462struct EncryptedObject {
463    first_block_index: u64,
464    data_block_count: u32,
465    parity_block_count: u32,
466    encrypted_size: u32,
467    records: Vec<BlockRecord>,
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471struct ObjectExtent {
472    first_block_index: u64,
473    data_block_count: u32,
474    parity_block_count: u32,
475    encrypted_size: u32,
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479struct PlannedEncryptedObject {
480    data_block_count: u32,
481    parity_block_count: u32,
482    encrypted_size: u32,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486enum MetadataObjectKind {
487    IndexRoot,
488    Dictionary,
489}
490
491impl MetadataObjectKind {
492    fn too_large_error(self) -> FormatError {
493        match self {
494            Self::IndexRoot => FormatError::WriterUnsupported("IndexRoot too large"),
495            Self::Dictionary => FormatError::WriterUnsupported("dictionary object too large"),
496        }
497    }
498}
499
500impl ObjectExtent {
501    fn new(first_block_index: u64, plan: PlannedEncryptedObject) -> Result<Self, FormatError> {
502        Ok(Self {
503            first_block_index,
504            data_block_count: plan.data_block_count,
505            parity_block_count: plan.parity_block_count,
506            encrypted_size: plan.encrypted_size,
507        })
508    }
509
510    fn next_block_index(self) -> Result<u64, FormatError> {
511        checked_u64_add(
512            self.first_block_index,
513            self.data_block_count as u64 + self.parity_block_count as u64,
514            "next_block_index",
515        )
516    }
517}
518
519impl From<&EncryptedObject> for ObjectExtent {
520    fn from(object: &EncryptedObject) -> Self {
521        Self {
522            first_block_index: object.first_block_index,
523            data_block_count: object.data_block_count,
524            parity_block_count: object.parity_block_count,
525            encrypted_size: object.encrypted_size,
526        }
527    }
528}
529
530#[derive(Debug, Clone)]
531struct PlannedDirectoryHintObject {
532    hint_shard_index: u64,
533    compressed: Vec<u8>,
534    extent: ObjectExtent,
535}
536
537struct WriterPlan {
538    options: WriterOptions,
539    archive_uuid: [u8; 16],
540    session_id: [u8; 16],
541    crypto_header: Vec<u8>,
542    tar_members: Vec<TarMember>,
543    frames: Vec<PayloadFrame>,
544    payload_objects: Vec<PayloadObject>,
545    index_root_plaintext: Vec<u8>,
546    compressed_index_root: Vec<u8>,
547    index_root_extent: ObjectExtent,
548    index_shard_objects: Vec<PlannedIndexShardObject>,
549    shard_entries: Vec<ShardEntry>,
550    compressed_dictionary: Option<Vec<u8>>,
551    dictionary_extent: Option<(ObjectExtent, u32)>,
552    directory_hint_objects: Vec<PlannedDirectoryHintObject>,
553    directory_hint_entries: Vec<DirectoryHintShardEntry>,
554    root_auth_footer_length: Option<u32>,
555    total_block_count: u64,
556}
557
558struct PlannedIndexShardObject {
559    shard_index: u64,
560    compressed: Vec<u8>,
561    extent: ObjectExtent,
562}
563
564struct PayloadPlanning {
565    tar_members: Vec<TarMember>,
566    frames: Vec<PayloadFrame>,
567    payload_objects: Vec<PayloadObject>,
568    payload_block_count: u64,
569    tar_total_size: u64,
570    content_sha256: [u8; 32],
571}
572
573struct PayloadEnvelopeBuilder {
574    envelope_index: u64,
575    plaintext: Vec<u8>,
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub(crate) struct StreamingRegularMember {
580    pub archive_path: Vec<u8>,
581    pub file_data_size: u64,
582    pub mode: u32,
583    pub mtime: u64,
584}
585
586struct WriterEmissionState {
587    volume_headers: Vec<[u8; VOLUME_HEADER_LEN]>,
588    bytes_written: Vec<u64>,
589    record_counts: Vec<u64>,
590    data_leaf_hashes: Option<Vec<(u64, [u8; 32])>>,
591    next_block_index: u64,
592}
593
594pub(crate) struct StreamingArchiveWriter<'a, O: ArchiveWriteSink> {
595    sink: &'a mut O,
596    options: WriterOptions,
597    archive_uuid: [u8; 16],
598    session_id: [u8; 16],
599    crypto_header: Vec<u8>,
600    subkeys: Subkeys,
601    tar_members: Vec<TarMember>,
602    frames: Vec<PayloadFrame>,
603    payload_objects: Vec<PayloadObject>,
604    payload_block_count: u64,
605    tar_total_size: u64,
606    hasher: Sha256,
607    next_frame_index: u64,
608    envelope: PayloadEnvelopeBuilder,
609    emission_state: WriterEmissionState,
610}
611
612pub fn write_archive(
613    files: &[RegularFile<'_>],
614    master_key: &MasterKey,
615    options: WriterOptions,
616) -> Result<WrittenArchive, FormatError> {
617    write_archive_inner(
618        files,
619        master_key,
620        options,
621        None,
622        &KdfParams::Raw,
623        None,
624        None,
625    )
626}
627
628pub fn write_archive_unencrypted(
629    files: &[RegularFile<'_>],
630    mut options: WriterOptions,
631) -> Result<WrittenArchive, FormatError> {
632    options.aead_algo = AeadAlgo::None;
633    let placeholder = MasterKey::from_raw_key(&[0; 32])?;
634    write_archive_inner(
635        files,
636        &placeholder,
637        options,
638        None,
639        &KdfParams::None,
640        None,
641        None,
642    )
643}
644
645pub fn write_archive_with_kdf(
646    files: &[RegularFile<'_>],
647    master_key: &MasterKey,
648    options: WriterOptions,
649    kdf_params: &KdfParams,
650) -> Result<WrittenArchive, FormatError> {
651    write_archive_inner(files, master_key, options, None, kdf_params, None, None)
652}
653
654pub fn write_archive_with_root_auth<F>(
655    files: &[RegularFile<'_>],
656    master_key: &MasterKey,
657    options: WriterOptions,
658    root_auth: RootAuthWriterConfig<'_>,
659    mut authenticator: F,
660) -> Result<WrittenArchive, FormatError>
661where
662    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
663{
664    write_archive_inner(
665        files,
666        master_key,
667        options,
668        None,
669        &KdfParams::Raw,
670        Some(root_auth),
671        Some(&mut authenticator),
672    )
673}
674
675pub fn write_archive_with_root_auth_and_kdf<F>(
676    files: &[RegularFile<'_>],
677    master_key: &MasterKey,
678    options: WriterOptions,
679    kdf_params: &KdfParams,
680    root_auth: RootAuthWriterConfig<'_>,
681    mut authenticator: F,
682) -> Result<WrittenArchive, FormatError>
683where
684    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
685{
686    write_archive_inner(
687        files,
688        master_key,
689        options,
690        None,
691        kdf_params,
692        Some(root_auth),
693        Some(&mut authenticator),
694    )
695}
696
697pub fn write_archive_with_dictionary_and_root_auth<F>(
698    files: &[RegularFile<'_>],
699    master_key: &MasterKey,
700    options: WriterOptions,
701    dictionary: &[u8],
702    root_auth: RootAuthWriterConfig<'_>,
703    mut authenticator: F,
704) -> Result<WrittenArchive, FormatError>
705where
706    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
707{
708    write_archive_inner(
709        files,
710        master_key,
711        options,
712        Some(dictionary),
713        &KdfParams::Raw,
714        Some(root_auth),
715        Some(&mut authenticator),
716    )
717}
718
719pub fn write_archive_with_dictionary_kdf_and_root_auth<F>(
720    files: &[RegularFile<'_>],
721    master_key: &MasterKey,
722    options: WriterOptions,
723    dictionary: &[u8],
724    kdf_params: &KdfParams,
725    root_auth: RootAuthWriterConfig<'_>,
726    mut authenticator: F,
727) -> Result<WrittenArchive, FormatError>
728where
729    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
730{
731    write_archive_inner(
732        files,
733        master_key,
734        options,
735        Some(dictionary),
736        kdf_params,
737        Some(root_auth),
738        Some(&mut authenticator),
739    )
740}
741
742pub fn write_archive_with_dictionary(
743    files: &[RegularFile<'_>],
744    master_key: &MasterKey,
745    options: WriterOptions,
746    dictionary: &[u8],
747) -> Result<WrittenArchive, FormatError> {
748    if dictionary.is_empty() {
749        return Err(FormatError::WriterUnsupported(
750            "dictionary archives require a non-empty dictionary",
751        ));
752    }
753    if files.is_empty() {
754        return Err(FormatError::WriterUnsupported(
755            "dictionary archives require at least one file",
756        ));
757    }
758    if dictionary.len() > u32::MAX as usize {
759        return Err(FormatError::WriterUnsupported(
760            "dictionary decompressed size exceeds u32",
761        ));
762    }
763    write_archive_inner(
764        files,
765        master_key,
766        options,
767        Some(dictionary),
768        &KdfParams::Raw,
769        None,
770        None,
771    )
772}
773
774pub fn write_archive_with_dictionary_and_kdf(
775    files: &[RegularFile<'_>],
776    master_key: &MasterKey,
777    options: WriterOptions,
778    dictionary: &[u8],
779    kdf_params: &KdfParams,
780) -> Result<WrittenArchive, FormatError> {
781    if dictionary.is_empty() {
782        return Err(FormatError::WriterUnsupported(
783            "dictionary archives require a non-empty dictionary",
784        ));
785    }
786    if files.is_empty() {
787        return Err(FormatError::WriterUnsupported(
788            "dictionary archives require at least one file",
789        ));
790    }
791    if dictionary.len() > u32::MAX as usize {
792        return Err(FormatError::WriterUnsupported(
793            "dictionary decompressed size exceeds u32",
794        ));
795    }
796    write_archive_inner(
797        files,
798        master_key,
799        options,
800        Some(dictionary),
801        kdf_params,
802        None,
803        None,
804    )
805}
806
807#[allow(clippy::too_many_arguments)]
808pub fn write_archive_sources_to_sink<S, O>(
809    files: &[S],
810    master_key: &MasterKey,
811    options: WriterOptions,
812    dictionary: Option<&[u8]>,
813    kdf_params: &KdfParams,
814    root_auth: Option<RootAuthWriterConfig<'_>>,
815    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
816    sink: &mut O,
817) -> Result<WrittenArchiveSummary, ArchiveWriteError>
818where
819    S: RegularFileSource,
820    O: ArchiveWriteSink,
821{
822    write_archive_stream_inner(
823        files,
824        master_key,
825        options,
826        dictionary,
827        kdf_params,
828        root_auth,
829        authenticator,
830        sink,
831    )
832}
833
834#[allow(clippy::too_many_arguments)]
835pub fn write_archive_sources_to_sink_with_progress<S, O>(
836    files: &[S],
837    master_key: &MasterKey,
838    options: WriterOptions,
839    dictionary: Option<&[u8]>,
840    kdf_params: &KdfParams,
841    root_auth: Option<RootAuthWriterConfig<'_>>,
842    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
843    sink: &mut O,
844    progress: &mut dyn ArchiveWriteProgressSink,
845) -> Result<WrittenArchiveSummary, ArchiveWriteError>
846where
847    S: RegularFileSource,
848    O: ArchiveWriteSink,
849{
850    let progress_files = progress_sources(files, progress);
851    write_archive_stream_inner(
852        &progress_files,
853        master_key,
854        options,
855        dictionary,
856        kdf_params,
857        root_auth,
858        authenticator,
859        sink,
860    )
861}
862
863#[allow(clippy::too_many_arguments)]
864pub fn write_archive_sources_to_sink_single_pass<S, O>(
865    files: &[S],
866    master_key: &MasterKey,
867    options: WriterOptions,
868    kdf_params: &KdfParams,
869    root_auth: Option<RootAuthWriterConfig<'_>>,
870    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
871    sink: &mut O,
872) -> Result<WrittenArchiveSummary, ArchiveWriteError>
873where
874    S: RegularFileSource,
875    O: ArchiveWriteSink,
876{
877    write_single_pass_archive_to_sink(
878        master_key,
879        options,
880        kdf_params,
881        root_auth,
882        authenticator,
883        sink,
884        |writer| {
885            for file in files {
886                let archive_path =
887                    normalize_lookup_file_path(file.archive_path(), options.max_path_length)?;
888                let mut reader = file.open()?;
889                writer.write_regular_member_from_reader(
890                    StreamingRegularMember {
891                        archive_path,
892                        file_data_size: file.file_data_size(),
893                        mode: file.mode(),
894                        mtime: file.mtime(),
895                    },
896                    reader.as_mut(),
897                )?;
898            }
899            Ok(())
900        },
901    )
902}
903
904#[allow(clippy::too_many_arguments)]
905pub fn write_archive_sources_to_sink_single_pass_with_progress<S, O>(
906    files: &[S],
907    master_key: &MasterKey,
908    options: WriterOptions,
909    kdf_params: &KdfParams,
910    root_auth: Option<RootAuthWriterConfig<'_>>,
911    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
912    sink: &mut O,
913    progress: &mut dyn ArchiveWriteProgressSink,
914) -> Result<WrittenArchiveSummary, ArchiveWriteError>
915where
916    S: RegularFileSource,
917    O: ArchiveWriteSink,
918{
919    let progress_files = progress_sources(files, progress);
920    write_archive_sources_to_sink_single_pass(
921        &progress_files,
922        master_key,
923        options,
924        kdf_params,
925        root_auth,
926        authenticator,
927        sink,
928    )
929}
930
931#[allow(clippy::too_many_arguments)]
932pub fn write_archive_sources_to_sink_ordered_parallel<S, O>(
933    files: &[S],
934    master_key: &MasterKey,
935    options: WriterOptions,
936    kdf_params: &KdfParams,
937    root_auth: Option<RootAuthWriterConfig<'_>>,
938    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
939    sink: &mut O,
940) -> Result<WrittenArchiveSummary, ArchiveWriteError>
941where
942    S: RegularFileSource,
943    O: ArchiveWriteSink,
944{
945    write_ordered_parallel_archive_to_sink(
946        files,
947        master_key,
948        options,
949        kdf_params,
950        root_auth,
951        authenticator,
952        sink,
953    )
954}
955
956#[allow(clippy::too_many_arguments)]
957pub fn write_archive_sources_to_sink_ordered_parallel_with_progress<S, O>(
958    files: &[S],
959    master_key: &MasterKey,
960    options: WriterOptions,
961    kdf_params: &KdfParams,
962    root_auth: Option<RootAuthWriterConfig<'_>>,
963    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
964    sink: &mut O,
965    progress: &mut dyn ArchiveWriteProgressSink,
966) -> Result<WrittenArchiveSummary, ArchiveWriteError>
967where
968    S: RegularFileSource,
969    O: ArchiveWriteSink,
970{
971    let progress_files = progress_sources(files, progress);
972    write_ordered_parallel_archive_to_sink(
973        &progress_files,
974        master_key,
975        options,
976        kdf_params,
977        root_auth,
978        authenticator,
979        sink,
980    )
981}
982
983fn write_archive_inner(
984    files: &[RegularFile<'_>],
985    master_key: &MasterKey,
986    options: WriterOptions,
987    dictionary: Option<&[u8]>,
988    kdf_params: &KdfParams,
989    root_auth: Option<RootAuthWriterConfig<'_>>,
990    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
991) -> Result<WrittenArchive, FormatError> {
992    let mut sink = MemoryArchiveSink::default();
993    let summary = write_archive_stream_inner(
994        files,
995        master_key,
996        options,
997        dictionary,
998        kdf_params,
999        root_auth,
1000        authenticator,
1001        &mut sink,
1002    )
1003    .map_err(format_error_from_archive_write_error)?;
1004    Ok(WrittenArchive {
1005        bytes: sink
1006            .volumes
1007            .first()
1008            .cloned()
1009            .ok_or(FormatError::WriterInvariant("no volumes emitted"))?,
1010        volumes: sink.volumes,
1011        bootstrap_sidecar: sink.bootstrap_sidecar,
1012        archive_uuid: summary.archive_uuid,
1013        session_id: summary.session_id,
1014        timings: summary.timings,
1015    })
1016}
1017
1018fn format_error_from_archive_write_error(error: ArchiveWriteError) -> FormatError {
1019    match error {
1020        ArchiveWriteError::Format(error) => error,
1021        ArchiveWriteError::Io(_) => {
1022            FormatError::WriterInvariant("in-memory archive writer returned I/O")
1023        }
1024    }
1025}
1026
1027fn writer_subkeys(
1028    master_key: &MasterKey,
1029    aead_algo: AeadAlgo,
1030    archive_uuid: &[u8; 16],
1031    session_id: &[u8; 16],
1032) -> Result<Subkeys, FormatError> {
1033    if aead_algo.is_encrypted() {
1034        Subkeys::derive(master_key, archive_uuid, session_id)
1035    } else {
1036        Ok(Subkeys::unencrypted_placeholder())
1037    }
1038}
1039
1040#[allow(clippy::too_many_arguments)]
1041fn write_archive_stream_inner<S, O>(
1042    files: &[S],
1043    master_key: &MasterKey,
1044    options: WriterOptions,
1045    dictionary: Option<&[u8]>,
1046    kdf_params: &KdfParams,
1047    root_auth: Option<RootAuthWriterConfig<'_>>,
1048    mut authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1049    sink: &mut O,
1050) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1051where
1052    S: RegularFileSource,
1053    O: ArchiveWriteSink,
1054{
1055    let total_started = Instant::now();
1056    validate_dictionary_inputs(files.is_empty(), dictionary)?;
1057    if let Some(root_auth) = root_auth {
1058        validate_root_auth_writer_config(root_auth)?;
1059    }
1060    let mut requested_options = options;
1061    if requested_options.target_volume_size.is_some() {
1062        requested_options.stripe_width = requested_options
1063            .stripe_width
1064            .max(requested_options.volume_loss_tolerance as u32 + 1);
1065    }
1066    let archive_uuid = requested_options
1067        .archive_uuid
1068        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1069    let session_id = requested_options
1070        .session_id
1071        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1072    let mut accumulated_timings = WriterTimings::default();
1073
1074    loop {
1075        let planned_options = plan_writer_options(requested_options)?;
1076        let timed_plan = build_writer_plan(
1077            files,
1078            master_key,
1079            planned_options,
1080            dictionary,
1081            kdf_params,
1082            archive_uuid,
1083            session_id,
1084            root_auth,
1085        )?;
1086        accumulated_timings.add_assign(timed_plan.timings);
1087        let plan = timed_plan.plan;
1088        if let Some(target_volume_size) = planned_options.target_volume_size {
1089            let required = required_stripe_width_for_plan(&plan, master_key, target_volume_size)?;
1090            if required > planned_options.stripe_width {
1091                requested_options.stripe_width = required;
1092                continue;
1093            }
1094        }
1095        let mut summary = emit_writer_plan(
1096            files,
1097            master_key,
1098            dictionary,
1099            root_auth,
1100            authenticator.take(),
1101            plan,
1102            sink,
1103        )?;
1104        summary.timings.add_assign(accumulated_timings);
1105        summary.timings.total = total_started.elapsed();
1106        return Ok(summary);
1107    }
1108}
1109
1110fn validate_dictionary_inputs(
1111    files_are_empty: bool,
1112    dictionary: Option<&[u8]>,
1113) -> Result<(), FormatError> {
1114    if let Some(dictionary) = dictionary {
1115        if dictionary.is_empty() {
1116            return Err(FormatError::WriterUnsupported(
1117                "dictionary archives require a non-empty dictionary",
1118            ));
1119        }
1120        if files_are_empty {
1121            return Err(FormatError::WriterUnsupported(
1122                "dictionary archives require at least one file",
1123            ));
1124        }
1125        if dictionary.len() > u32::MAX as usize {
1126            return Err(FormatError::WriterUnsupported(
1127                "dictionary decompressed size exceeds u32",
1128            ));
1129        }
1130    }
1131    Ok(())
1132}
1133
1134#[allow(clippy::too_many_arguments)]
1135struct TimedWriterPlan {
1136    plan: WriterPlan,
1137    timings: WriterTimings,
1138}
1139
1140#[allow(clippy::too_many_arguments)]
1141fn build_writer_plan<S: RegularFileSource>(
1142    files: &[S],
1143    master_key: &MasterKey,
1144    options: WriterOptions,
1145    dictionary: Option<&[u8]>,
1146    kdf_params: &KdfParams,
1147    archive_uuid: [u8; 16],
1148    session_id: [u8; 16],
1149    root_auth: Option<RootAuthWriterConfig<'_>>,
1150) -> Result<TimedWriterPlan, ArchiveWriteError> {
1151    let mut next_block_index = 0u64;
1152    let payload_started = Instant::now();
1153    let payload = plan_payload_stream(files, options, dictionary, &mut next_block_index)?;
1154    let plan_payload = payload_started.elapsed();
1155    let metadata_started = Instant::now();
1156    let plan = build_writer_plan_from_payload(
1157        payload,
1158        next_block_index,
1159        master_key,
1160        options,
1161        dictionary,
1162        kdf_params,
1163        archive_uuid,
1164        session_id,
1165        root_auth,
1166    )?;
1167    Ok(TimedWriterPlan {
1168        plan,
1169        timings: WriterTimings {
1170            plan_payload,
1171            plan_metadata: metadata_started.elapsed(),
1172            ..WriterTimings::default()
1173        },
1174    })
1175}
1176
1177#[allow(clippy::too_many_arguments)]
1178fn build_writer_plan_from_payload(
1179    payload: PayloadPlanning,
1180    mut next_block_index: u64,
1181    master_key: &MasterKey,
1182    mut options: WriterOptions,
1183    dictionary: Option<&[u8]>,
1184    kdf_params: &KdfParams,
1185    archive_uuid: [u8; 16],
1186    session_id: [u8; 16],
1187    root_auth: Option<RootAuthWriterConfig<'_>>,
1188) -> Result<WriterPlan, ArchiveWriteError> {
1189    let subkeys = writer_subkeys(master_key, options.aead_algo, &archive_uuid, &session_id)?;
1190    let (shard_file_rows, planned_index_shards) = if payload.tar_members.is_empty() {
1191        (Vec::new(), Vec::new())
1192    } else {
1193        let rows = sorted_file_rows(&payload.tar_members);
1194        let shard_file_rows = partition_file_rows(rows)?;
1195        let planned_index_shards = build_index_shard_plaintexts(
1196            &shard_file_rows,
1197            &payload.frames,
1198            &payload.payload_objects,
1199            options,
1200        )?;
1201        (shard_file_rows, planned_index_shards)
1202    };
1203
1204    let mut shard_entries = Vec::with_capacity(planned_index_shards.len());
1205    let mut index_shard_objects = Vec::with_capacity(planned_index_shards.len());
1206    for planned in planned_index_shards {
1207        let compressed =
1208            compress_zstd_frame_with_jobs(&planned.plaintext, options.zstd_level, options.jobs)?;
1209        let object_plan = plan_encrypted_object(
1210            compressed.len(),
1211            options.index_fec_data_shards,
1212            options.index_fec_parity_shards,
1213            options,
1214        )?;
1215        let extent = ObjectExtent::new(next_block_index, object_plan)?;
1216        next_block_index = extent.next_block_index()?;
1217        shard_entries.push(ShardEntry {
1218            shard_index: planned.shard_index,
1219            first_block_index: extent.first_block_index,
1220            data_block_count: extent.data_block_count,
1221            parity_block_count: extent.parity_block_count,
1222            encrypted_size: extent.encrypted_size,
1223            decompressed_size: u32_len(planned.plaintext.len(), "IndexShard")?,
1224            file_count: planned.file_count,
1225            first_path_hash: planned.first_path_hash,
1226            last_path_hash: planned.last_path_hash,
1227        });
1228        index_shard_objects.push(PlannedIndexShardObject {
1229            shard_index: planned.shard_index,
1230            compressed,
1231            extent,
1232        });
1233    }
1234
1235    let compressed_dictionary = dictionary
1236        .map(|dictionary| {
1237            compress_zstd_frame_with_jobs(dictionary, options.zstd_level, options.jobs)
1238        })
1239        .transpose()?;
1240    let dictionary_decompressed_size = dictionary
1241        .map(|dictionary| u32_len(dictionary.len(), "dictionary"))
1242        .transpose()?;
1243    let dictionary_plan = compressed_dictionary
1244        .as_ref()
1245        .map(|compressed| {
1246            plan_metadata_object_without_class(
1247                compressed.len(),
1248                options,
1249                MetadataObjectKind::Dictionary,
1250            )
1251        })
1252        .transpose()?;
1253    let dictionary_extent = dictionary_plan
1254        .map(|plan| ObjectExtent::new(next_block_index, plan))
1255        .transpose()?;
1256    let next_after_dictionary = if let Some(extent) = dictionary_extent {
1257        extent.next_block_index()?
1258    } else {
1259        next_block_index
1260    };
1261
1262    let planned_directory_hint_shards = if should_emit_directory_hints(payload.tar_members.len()) {
1263        build_directory_hint_plaintexts(&shard_file_rows, options)?
1264    } else {
1265        Vec::new()
1266    };
1267    let mut directory_hint_entries = Vec::with_capacity(planned_directory_hint_shards.len());
1268    let mut directory_hint_objects = Vec::with_capacity(planned_directory_hint_shards.len());
1269    let mut planned_next_block_index = next_after_dictionary;
1270    for planned in planned_directory_hint_shards {
1271        let compressed =
1272            compress_zstd_frame_with_jobs(&planned.plaintext, options.zstd_level, options.jobs)?;
1273        let object_plan = plan_encrypted_object(
1274            compressed.len(),
1275            options.index_fec_data_shards,
1276            options.index_fec_parity_shards,
1277            options,
1278        )?;
1279        let extent = ObjectExtent::new(planned_next_block_index, object_plan)?;
1280        planned_next_block_index = extent.next_block_index()?;
1281        directory_hint_entries.push(DirectoryHintShardEntry {
1282            hint_shard_index: planned.hint_shard_index,
1283            first_dir_hash: planned.first_dir_hash,
1284            last_dir_hash: planned.last_dir_hash,
1285            first_block_index: extent.first_block_index,
1286            data_block_count: extent.data_block_count,
1287            parity_block_count: extent.parity_block_count,
1288            encrypted_size: extent.encrypted_size,
1289            decompressed_size: u32_len(planned.plaintext.len(), "DirectoryHintTable")?,
1290            entry_count: planned.entry_count,
1291        });
1292        directory_hint_objects.push(PlannedDirectoryHintObject {
1293            hint_shard_index: planned.hint_shard_index,
1294            compressed,
1295            extent,
1296        });
1297    }
1298
1299    let dictionary_extent = dictionary_extent.zip(dictionary_decompressed_size);
1300    let index_root_plaintext = build_index_root_plaintext(IndexRootPlaintextInput {
1301        shard_entries: &shard_entries,
1302        frame_count: payload.frames.len() as u64,
1303        envelope_count: payload.payload_objects.len() as u64,
1304        file_count: payload.tar_members.len() as u64,
1305        payload_block_count: payload.payload_block_count,
1306        tar_total_size: payload.tar_total_size,
1307        content_sha256: payload.content_sha256,
1308        directory_hint_entries: &directory_hint_entries,
1309        dictionary_extent,
1310    });
1311    let compressed_index_root =
1312        compress_zstd_frame_with_jobs(&index_root_plaintext, options.zstd_level, options.jobs)?;
1313    let metadata_class = plan_index_root_metadata_class(
1314        options,
1315        compressed_index_root.len(),
1316        compressed_dictionary.as_ref().map(Vec::len),
1317    )?;
1318    options = metadata_class.options;
1319    let crypto_header = build_crypto_header(
1320        options,
1321        dictionary.is_some(),
1322        &subkeys,
1323        &archive_uuid,
1324        &session_id,
1325        kdf_params,
1326    )?;
1327    let index_root_extent = ObjectExtent::new(planned_next_block_index, metadata_class.index_root)?;
1328    let total_block_count = index_root_extent.next_block_index()?;
1329    let root_auth_footer_length = root_auth
1330        .map(|config| {
1331            root_auth_footer_wire_length(
1332                config.signer_identity.len(),
1333                config.authenticator_value_length as usize,
1334            )
1335        })
1336        .transpose()?;
1337
1338    Ok(WriterPlan {
1339        options,
1340        archive_uuid,
1341        session_id,
1342        crypto_header,
1343        tar_members: payload.tar_members,
1344        frames: payload.frames,
1345        payload_objects: payload.payload_objects,
1346        index_root_plaintext,
1347        compressed_index_root,
1348        index_root_extent,
1349        index_shard_objects,
1350        shard_entries,
1351        compressed_dictionary,
1352        dictionary_extent,
1353        directory_hint_objects,
1354        directory_hint_entries,
1355        root_auth_footer_length,
1356        total_block_count,
1357    })
1358}
1359
1360fn plan_payload_stream<S: RegularFileSource>(
1361    files: &[S],
1362    options: WriterOptions,
1363    dictionary: Option<&[u8]>,
1364    next_block_index: &mut u64,
1365) -> Result<PayloadPlanning, ArchiveWriteError> {
1366    let mut tar_members = Vec::with_capacity(files.len());
1367    let mut frames = Vec::new();
1368    let mut payload_objects = Vec::new();
1369    let mut tar_total_size = 0u64;
1370    let mut hasher = Sha256::new();
1371    let mut payload_block_count = 0u64;
1372    let mut next_frame_index = 0u64;
1373    let mut envelope = PayloadEnvelopeBuilder {
1374        envelope_index: 0,
1375        plaintext: Vec::new(),
1376    };
1377
1378    for (member_index, file) in files.iter().enumerate() {
1379        let path = normalize_lookup_file_path(file.archive_path(), options.max_path_length)?;
1380        let prefix = build_regular_file_member_prefix(
1381            &path,
1382            file.file_data_size(),
1383            file.mode(),
1384            file.mtime(),
1385        )?;
1386        let member_start = tar_total_size;
1387        let member_group_size = checked_u64_add(
1388            prefix.len() as u64,
1389            checked_u64_add(
1390                file.file_data_size(),
1391                padding_to_512_u64(file.file_data_size()),
1392                "tar member",
1393            )?,
1394            "tar member",
1395        )?;
1396        tar_members.push(TarMember {
1397            path,
1398            tar_member_group_start: member_start,
1399            tar_member_group_size: member_group_size,
1400            file_data_size: file.file_data_size(),
1401            mode: file.mode(),
1402            mtime: file.mtime(),
1403        });
1404        let mut reader = StreamingMemberReader::new(file.open()?, prefix, file.file_data_size());
1405        let mut member_offset = 0u64;
1406        while member_offset < member_group_size {
1407            let remaining = member_group_size - member_offset;
1408            let max_chunk = remaining.min(options.chunk_size as u64);
1409            let mut chunk = vec![0u8; to_usize_writer(max_chunk, "payload chunk")?];
1410            reader
1411                .read_exact(&mut chunk)
1412                .map_err(ArchiveWriteError::Io)?;
1413            let mut chunk_len = chunk.len();
1414            let frame = loop {
1415                let candidate = &chunk[..chunk_len];
1416                let frame = if let Some(dictionary) = dictionary {
1417                    compress_zstd_frame_with_dictionary_and_jobs(
1418                        candidate,
1419                        options.zstd_level,
1420                        dictionary,
1421                        options.jobs,
1422                    )?
1423                } else {
1424                    compress_zstd_frame_with_jobs(candidate, options.zstd_level, options.jobs)?
1425                };
1426                if payload_object_can_fit(frame.len(), options)? {
1427                    break frame;
1428                }
1429                if chunk_len == 1 {
1430                    return Err(FormatError::WriterUnsupported(
1431                        "single-byte payload frame exceeds envelope object limits",
1432                    )
1433                    .into());
1434                }
1435                chunk_len = (chunk_len / 2).max(1);
1436            };
1437            if chunk_len < chunk.len() {
1438                reader.push_back(chunk[chunk_len..].to_vec());
1439            }
1440            let chunk = &chunk[..chunk_len];
1441            hasher.update(chunk);
1442            append_payload_frame_to_plan(
1443                PayloadFramePlanState {
1444                    envelope: &mut envelope,
1445                    payload_objects: &mut payload_objects,
1446                    payload_block_count: &mut payload_block_count,
1447                    next_block_index,
1448                    frames: &mut frames,
1449                    next_frame_index: &mut next_frame_index,
1450                    options,
1451                },
1452                PayloadFramePlanInput {
1453                    frame: &frame,
1454                    decompressed_size: chunk_len,
1455                    member_index,
1456                    member_start,
1457                    member_offset,
1458                    member_group_size,
1459                },
1460            )?;
1461            member_offset = checked_u64_add(member_offset, chunk_len as u64, "payload chunk")?;
1462            tar_total_size = checked_u64_add(tar_total_size, chunk_len as u64, "tar stream")?;
1463        }
1464    }
1465
1466    if !envelope.plaintext.is_empty() {
1467        flush_payload_envelope_plan(
1468            &mut envelope,
1469            &mut payload_objects,
1470            &mut payload_block_count,
1471            next_block_index,
1472            options,
1473        )?;
1474    }
1475    let digest = hasher.finalize();
1476    let mut content_sha256 = [0u8; 32];
1477    content_sha256.copy_from_slice(&digest);
1478    Ok(PayloadPlanning {
1479        tar_members,
1480        frames,
1481        payload_objects,
1482        payload_block_count,
1483        tar_total_size,
1484        content_sha256,
1485    })
1486}
1487
1488#[allow(clippy::too_many_arguments)]
1489pub(crate) fn write_single_pass_archive_to_sink<O, F>(
1490    master_key: &MasterKey,
1491    options: WriterOptions,
1492    kdf_params: &KdfParams,
1493    root_auth: Option<RootAuthWriterConfig<'_>>,
1494    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1495    sink: &mut O,
1496    drive_members: F,
1497) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1498where
1499    O: ArchiveWriteSink,
1500    F: FnOnce(&mut StreamingArchiveWriter<'_, O>) -> Result<(), ArchiveWriteError>,
1501{
1502    let total_started = Instant::now();
1503    validate_single_pass_writer_options(options)?;
1504    if let Some(root_auth) = root_auth {
1505        validate_root_auth_writer_config(root_auth)?;
1506    }
1507    let options = plan_single_pass_writer_options(options)?;
1508    let archive_uuid = options
1509        .archive_uuid
1510        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1511    let session_id = options
1512        .session_id
1513        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1514    let subkeys = writer_subkeys(master_key, options.aead_algo, &archive_uuid, &session_id)?;
1515    let crypto_header = build_crypto_header(
1516        options,
1517        false,
1518        &subkeys,
1519        &archive_uuid,
1520        &session_id,
1521        kdf_params,
1522    )?;
1523    let emission_state = begin_writer_emission_state(
1524        sink,
1525        options,
1526        &crypto_header,
1527        archive_uuid,
1528        session_id,
1529        root_auth.is_some(),
1530    )?;
1531
1532    let mut writer = StreamingArchiveWriter {
1533        sink,
1534        options,
1535        archive_uuid,
1536        session_id,
1537        crypto_header,
1538        subkeys,
1539        tar_members: Vec::new(),
1540        frames: Vec::new(),
1541        payload_objects: Vec::new(),
1542        payload_block_count: 0,
1543        tar_total_size: 0,
1544        hasher: Sha256::new(),
1545        next_frame_index: 0,
1546        envelope: PayloadEnvelopeBuilder {
1547            envelope_index: 0,
1548            plaintext: Vec::new(),
1549        },
1550        emission_state,
1551    };
1552    let emit_payload_started = Instant::now();
1553    drive_members(&mut writer)?;
1554    let emit_payload = emit_payload_started.elapsed();
1555    let mut summary = writer.finish(master_key, kdf_params, root_auth, authenticator)?;
1556    summary.timings.emit_payload += emit_payload;
1557    summary.timings.total = total_started.elapsed();
1558    Ok(summary)
1559}
1560
1561struct OrderedFrameJob {
1562    frame_index: u64,
1563    member_index: usize,
1564    member_start: u64,
1565    member_offset: u64,
1566    member_group_size: u64,
1567    plaintext: Vec<u8>,
1568}
1569
1570struct OrderedFrameResult {
1571    frame_index: u64,
1572    member_index: usize,
1573    member_start: u64,
1574    member_offset: u64,
1575    member_group_size: u64,
1576    decompressed_size: usize,
1577    frame: Vec<u8>,
1578}
1579
1580struct OrderedEnvelopeJob {
1581    envelope_index: u64,
1582    plaintext: Vec<u8>,
1583    extent: ObjectExtent,
1584    collect_data_leaf_hashes: bool,
1585}
1586
1587struct OrderedEnvelopeResult {
1588    envelope_index: u64,
1589    records: OrderedEnvelopeRecords,
1590}
1591
1592enum OrderedEnvelopeRecords {
1593    Materialized(Vec<BlockRecord>),
1594    Serialized(Vec<SerializedBlockRecord>),
1595}
1596
1597struct SerializedBlockRecord {
1598    block_index: u64,
1599    bytes: Vec<u8>,
1600}
1601
1602struct OrderedParallelState {
1603    tar_members: Vec<TarMember>,
1604    frames: Vec<PayloadFrame>,
1605    payload_objects: Vec<PayloadObject>,
1606    payload_block_count: u64,
1607    tar_total_size: u64,
1608    hasher: Sha256,
1609    next_frame_job_index: u64,
1610    next_frame_result_index: u64,
1611    next_frame_metadata_index: u64,
1612    frame_buffer: std::collections::BTreeMap<u64, OrderedFrameResult>,
1613    envelope: PayloadEnvelopeBuilder,
1614    next_payload_block_index: u64,
1615    next_envelope_result_index: u64,
1616    envelope_buffer: std::collections::BTreeMap<u64, OrderedEnvelopeResult>,
1617}
1618
1619impl OrderedParallelState {
1620    fn new(file_count: usize) -> Self {
1621        Self {
1622            tar_members: Vec::with_capacity(file_count),
1623            frames: Vec::new(),
1624            payload_objects: Vec::new(),
1625            payload_block_count: 0,
1626            tar_total_size: 0,
1627            hasher: Sha256::new(),
1628            next_frame_job_index: 0,
1629            next_frame_result_index: 0,
1630            next_frame_metadata_index: 0,
1631            frame_buffer: std::collections::BTreeMap::new(),
1632            envelope: PayloadEnvelopeBuilder {
1633                envelope_index: 0,
1634                plaintext: Vec::new(),
1635            },
1636            next_payload_block_index: 0,
1637            next_envelope_result_index: 0,
1638            envelope_buffer: std::collections::BTreeMap::new(),
1639        }
1640    }
1641}
1642
1643#[allow(clippy::too_many_arguments)]
1644fn write_ordered_parallel_archive_to_sink<S, O>(
1645    files: &[S],
1646    master_key: &MasterKey,
1647    options: WriterOptions,
1648    kdf_params: &KdfParams,
1649    root_auth: Option<RootAuthWriterConfig<'_>>,
1650    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1651    sink: &mut O,
1652) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1653where
1654    S: RegularFileSource,
1655    O: ArchiveWriteSink,
1656{
1657    let total_started = Instant::now();
1658    validate_single_pass_writer_options(options)?;
1659    if let Some(root_auth) = root_auth {
1660        validate_root_auth_writer_config(root_auth)?;
1661    }
1662    let options = plan_single_pass_writer_options(options)?;
1663    let archive_uuid = options
1664        .archive_uuid
1665        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1666    let session_id = options
1667        .session_id
1668        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1669    let subkeys = writer_subkeys(master_key, options.aead_algo, &archive_uuid, &session_id)?;
1670    let crypto_header = build_crypto_header(
1671        options,
1672        false,
1673        &subkeys,
1674        &archive_uuid,
1675        &session_id,
1676        kdf_params,
1677    )?;
1678    let mut emission_state = begin_writer_emission_state(
1679        sink,
1680        options,
1681        &crypto_header,
1682        archive_uuid,
1683        session_id,
1684        root_auth.is_some(),
1685    )?;
1686
1687    let emit_payload_started = Instant::now();
1688    let mut ordered = OrderedParallelState::new(files.len());
1689    let worker_count = options.jobs.max(1);
1690    let frame_job_buffer = worker_count.saturating_mul(4).max(1);
1691    let envelope_job_buffer = worker_count.saturating_mul(2).max(1);
1692    let subkeys_for_workers = std::sync::Arc::new(subkeys.clone());
1693
1694    std::thread::scope(|scope| -> Result<(), ArchiveWriteError> {
1695        let (frame_job_tx, frame_job_rx) =
1696            std::sync::mpsc::sync_channel::<OrderedFrameJob>(frame_job_buffer);
1697        let (frame_result_tx, frame_result_rx) =
1698            std::sync::mpsc::channel::<Result<OrderedFrameResult, ArchiveWriteError>>();
1699        let frame_job_rx = std::sync::Arc::new(std::sync::Mutex::new(frame_job_rx));
1700
1701        let (envelope_job_tx, envelope_job_rx) =
1702            std::sync::mpsc::sync_channel::<OrderedEnvelopeJob>(envelope_job_buffer);
1703        let (envelope_result_tx, envelope_result_rx) =
1704            std::sync::mpsc::channel::<Result<OrderedEnvelopeResult, ArchiveWriteError>>();
1705        let envelope_job_rx = std::sync::Arc::new(std::sync::Mutex::new(envelope_job_rx));
1706
1707        let frame_handles = (0..worker_count)
1708            .map(|_| {
1709                let frame_job_rx = std::sync::Arc::clone(&frame_job_rx);
1710                let frame_result_tx = frame_result_tx.clone();
1711                scope.spawn(move || loop {
1712                    let job = {
1713                        let receiver = frame_job_rx
1714                            .lock()
1715                            .expect("ordered frame receiver poisoned");
1716                        receiver.recv()
1717                    };
1718                    let Ok(job) = job else {
1719                        break;
1720                    };
1721                    let is_error = match build_ordered_frame_result(job, options) {
1722                        Ok(result) => frame_result_tx.send(Ok(result)).is_err(),
1723                        Err(error) => {
1724                            let _ = frame_result_tx.send(Err(error));
1725                            true
1726                        }
1727                    };
1728                    if is_error {
1729                        break;
1730                    }
1731                })
1732            })
1733            .collect::<Vec<_>>();
1734        drop(frame_result_tx);
1735
1736        let envelope_handles = (0..worker_count)
1737            .map(|_| {
1738                let envelope_job_rx = std::sync::Arc::clone(&envelope_job_rx);
1739                let envelope_result_tx = envelope_result_tx.clone();
1740                let subkeys = std::sync::Arc::clone(&subkeys_for_workers);
1741                scope.spawn(move || loop {
1742                    let job = {
1743                        let receiver = envelope_job_rx
1744                            .lock()
1745                            .expect("ordered envelope receiver poisoned");
1746                        receiver.recv()
1747                    };
1748                    let Ok(job) = job else {
1749                        break;
1750                    };
1751                    let is_error = match build_ordered_envelope_result(
1752                        job,
1753                        &subkeys,
1754                        options,
1755                        archive_uuid,
1756                        session_id,
1757                    ) {
1758                        Ok(result) => envelope_result_tx.send(Ok(result)).is_err(),
1759                        Err(error) => {
1760                            let _ = envelope_result_tx.send(Err(error));
1761                            true
1762                        }
1763                    };
1764                    if is_error {
1765                        break;
1766                    }
1767                })
1768            })
1769            .collect::<Vec<_>>();
1770        drop(envelope_result_tx);
1771
1772        for (member_index, file) in files.iter().enumerate() {
1773            let path = normalize_lookup_file_path(file.archive_path(), options.max_path_length)?;
1774            let prefix = build_regular_file_member_prefix(
1775                &path,
1776                file.file_data_size(),
1777                file.mode(),
1778                file.mtime(),
1779            )?;
1780            let member_start = ordered.tar_total_size;
1781            let member_group_size = checked_u64_add(
1782                prefix.len() as u64,
1783                checked_u64_add(
1784                    file.file_data_size(),
1785                    padding_to_512_u64(file.file_data_size()),
1786                    "tar member",
1787                )?,
1788                "tar member",
1789            )?;
1790            ordered.tar_members.push(TarMember {
1791                path,
1792                tar_member_group_start: member_start,
1793                tar_member_group_size: member_group_size,
1794                file_data_size: file.file_data_size(),
1795                mode: file.mode(),
1796                mtime: file.mtime(),
1797            });
1798            let mut reader =
1799                StreamingMemberReader::new(file.open()?, prefix, file.file_data_size());
1800            let mut member_offset = 0u64;
1801            while member_offset < member_group_size {
1802                let remaining = member_group_size - member_offset;
1803                let read_len = remaining.min(options.chunk_size as u64);
1804                let mut plaintext = vec![0u8; to_usize_writer(read_len, "payload chunk")?];
1805                reader
1806                    .read_exact(&mut plaintext)
1807                    .map_err(ArchiveWriteError::Io)?;
1808                ordered.hasher.update(&plaintext);
1809                let frame_index = ordered.next_frame_job_index;
1810                ordered.next_frame_job_index =
1811                    checked_u64_add(ordered.next_frame_job_index, 1, "PayloadFrame.frame_index")?;
1812                send_ordered_frame_job(
1813                    OrderedFrameJob {
1814                        frame_index,
1815                        member_index,
1816                        member_start,
1817                        member_offset,
1818                        member_group_size,
1819                        plaintext,
1820                    },
1821                    &frame_job_tx,
1822                    &frame_result_rx,
1823                    &envelope_job_tx,
1824                    &envelope_result_rx,
1825                    &mut ordered,
1826                    sink,
1827                    options,
1828                    &mut emission_state,
1829                )?;
1830                member_offset = checked_u64_add(member_offset, read_len, "payload chunk")?;
1831                ordered.tar_total_size =
1832                    checked_u64_add(ordered.tar_total_size, read_len, "tar stream")?;
1833            }
1834        }
1835        drop(frame_job_tx);
1836
1837        while ordered.next_frame_result_index < ordered.next_frame_job_index {
1838            receive_ordered_frame_result(
1839                &frame_result_rx,
1840                &envelope_job_tx,
1841                &envelope_result_rx,
1842                &mut ordered,
1843                sink,
1844                options,
1845                &mut emission_state,
1846                true,
1847            )?;
1848        }
1849        flush_ordered_parallel_envelope(
1850            &envelope_job_tx,
1851            &envelope_result_rx,
1852            &mut ordered,
1853            sink,
1854            options,
1855            &mut emission_state,
1856        )?;
1857        drop(envelope_job_tx);
1858        while ordered.next_envelope_result_index < ordered.envelope.envelope_index {
1859            receive_ordered_envelope_result(
1860                &envelope_result_rx,
1861                &mut ordered,
1862                sink,
1863                options,
1864                &mut emission_state,
1865                true,
1866            )?;
1867        }
1868
1869        for handle in frame_handles {
1870            handle
1871                .join()
1872                .map_err(|_| FormatError::WriterInvariant("ordered frame worker panicked"))?;
1873        }
1874        for handle in envelope_handles {
1875            handle
1876                .join()
1877                .map_err(|_| FormatError::WriterInvariant("ordered envelope worker panicked"))?;
1878        }
1879        Ok(())
1880    })?;
1881    let emit_payload = emit_payload_started.elapsed();
1882
1883    emission_state.next_block_index = ordered.next_payload_block_index;
1884    let digest = ordered.hasher.finalize();
1885    let mut content_sha256 = [0u8; 32];
1886    content_sha256.copy_from_slice(&digest);
1887    let payload = PayloadPlanning {
1888        tar_members: ordered.tar_members,
1889        frames: ordered.frames,
1890        payload_objects: ordered.payload_objects,
1891        payload_block_count: ordered.payload_block_count,
1892        tar_total_size: ordered.tar_total_size,
1893        content_sha256,
1894    };
1895    let plan = build_writer_plan_from_payload(
1896        payload,
1897        emission_state.next_block_index,
1898        master_key,
1899        options,
1900        None,
1901        kdf_params,
1902        archive_uuid,
1903        session_id,
1904        root_auth,
1905    )?;
1906    if plan.options != options || plan.crypto_header != crypto_header {
1907        return Err(FormatError::WriterUnsupported(
1908            "ordered parallel metadata exceeded the predeclared header class",
1909        )
1910        .into());
1911    }
1912    let mut summary = emit_writer_plan_suffix(
1913        &subkeys,
1914        root_auth,
1915        authenticator,
1916        plan,
1917        sink,
1918        emission_state,
1919    )?;
1920    summary.timings.emit_payload += emit_payload;
1921    summary.timings.total = total_started.elapsed();
1922    Ok(summary)
1923}
1924
1925#[allow(clippy::too_many_arguments)]
1926pub(crate) fn write_ordered_parallel_stream_archive_to_sink<O, F>(
1927    master_key: &MasterKey,
1928    options: WriterOptions,
1929    kdf_params: &KdfParams,
1930    root_auth: Option<RootAuthWriterConfig<'_>>,
1931    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
1932    sink: &mut O,
1933    drive_members: F,
1934) -> Result<WrittenArchiveSummary, ArchiveWriteError>
1935where
1936    O: ArchiveWriteSink,
1937    F: FnOnce(&mut OrderedParallelArchiveWriter<'_, O>) -> Result<(), ArchiveWriteError>,
1938{
1939    let total_started = Instant::now();
1940    validate_single_pass_writer_options(options)?;
1941    if let Some(root_auth) = root_auth {
1942        validate_root_auth_writer_config(root_auth)?;
1943    }
1944    let options = plan_single_pass_writer_options(options)?;
1945    let archive_uuid = options
1946        .archive_uuid
1947        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1948    let session_id = options
1949        .session_id
1950        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
1951    let subkeys = writer_subkeys(master_key, options.aead_algo, &archive_uuid, &session_id)?;
1952    let crypto_header = build_crypto_header(
1953        options,
1954        false,
1955        &subkeys,
1956        &archive_uuid,
1957        &session_id,
1958        kdf_params,
1959    )?;
1960    let mut emission_state = begin_writer_emission_state(
1961        sink,
1962        options,
1963        &crypto_header,
1964        archive_uuid,
1965        session_id,
1966        root_auth.is_some(),
1967    )?;
1968
1969    let emit_payload_started = Instant::now();
1970    let mut ordered = OrderedParallelState::new(0);
1971    let worker_count = options.jobs.max(1);
1972    let frame_job_buffer = worker_count.saturating_mul(4).max(1);
1973    let envelope_job_buffer = worker_count.saturating_mul(2).max(1);
1974    let subkeys_for_workers = std::sync::Arc::new(subkeys.clone());
1975
1976    std::thread::scope(|scope| -> Result<(), ArchiveWriteError> {
1977        let (frame_job_tx, frame_job_rx) =
1978            std::sync::mpsc::sync_channel::<OrderedFrameJob>(frame_job_buffer);
1979        let (frame_result_tx, frame_result_rx) =
1980            std::sync::mpsc::channel::<Result<OrderedFrameResult, ArchiveWriteError>>();
1981        let frame_job_rx = std::sync::Arc::new(std::sync::Mutex::new(frame_job_rx));
1982
1983        let (envelope_job_tx, envelope_job_rx) =
1984            std::sync::mpsc::sync_channel::<OrderedEnvelopeJob>(envelope_job_buffer);
1985        let (envelope_result_tx, envelope_result_rx) =
1986            std::sync::mpsc::channel::<Result<OrderedEnvelopeResult, ArchiveWriteError>>();
1987        let envelope_job_rx = std::sync::Arc::new(std::sync::Mutex::new(envelope_job_rx));
1988
1989        let frame_handles = (0..worker_count)
1990            .map(|_| {
1991                let frame_job_rx = std::sync::Arc::clone(&frame_job_rx);
1992                let frame_result_tx = frame_result_tx.clone();
1993                scope.spawn(move || loop {
1994                    let job = {
1995                        let receiver = frame_job_rx
1996                            .lock()
1997                            .expect("ordered frame receiver poisoned");
1998                        receiver.recv()
1999                    };
2000                    let Ok(job) = job else {
2001                        break;
2002                    };
2003                    let is_error = match build_ordered_frame_result(job, options) {
2004                        Ok(result) => frame_result_tx.send(Ok(result)).is_err(),
2005                        Err(error) => {
2006                            let _ = frame_result_tx.send(Err(error));
2007                            true
2008                        }
2009                    };
2010                    if is_error {
2011                        break;
2012                    }
2013                })
2014            })
2015            .collect::<Vec<_>>();
2016        drop(frame_result_tx);
2017
2018        let envelope_handles = (0..worker_count)
2019            .map(|_| {
2020                let envelope_job_rx = std::sync::Arc::clone(&envelope_job_rx);
2021                let envelope_result_tx = envelope_result_tx.clone();
2022                let subkeys = std::sync::Arc::clone(&subkeys_for_workers);
2023                scope.spawn(move || loop {
2024                    let job = {
2025                        let receiver = envelope_job_rx
2026                            .lock()
2027                            .expect("ordered envelope receiver poisoned");
2028                        receiver.recv()
2029                    };
2030                    let Ok(job) = job else {
2031                        break;
2032                    };
2033                    let is_error = match build_ordered_envelope_result(
2034                        job,
2035                        &subkeys,
2036                        options,
2037                        archive_uuid,
2038                        session_id,
2039                    ) {
2040                        Ok(result) => envelope_result_tx.send(Ok(result)).is_err(),
2041                        Err(error) => {
2042                            let _ = envelope_result_tx.send(Err(error));
2043                            true
2044                        }
2045                    };
2046                    if is_error {
2047                        break;
2048                    }
2049                })
2050            })
2051            .collect::<Vec<_>>();
2052        drop(envelope_result_tx);
2053
2054        {
2055            let mut writer = OrderedParallelArchiveWriter {
2056                frame_job_tx: &frame_job_tx,
2057                frame_result_rx: &frame_result_rx,
2058                envelope_job_tx: &envelope_job_tx,
2059                envelope_result_rx: &envelope_result_rx,
2060                ordered: &mut ordered,
2061                sink,
2062                options,
2063                emission_state: &mut emission_state,
2064            };
2065            drive_members(&mut writer)?;
2066        }
2067
2068        drop(frame_job_tx);
2069        while ordered.next_frame_result_index < ordered.next_frame_job_index {
2070            receive_ordered_frame_result(
2071                &frame_result_rx,
2072                &envelope_job_tx,
2073                &envelope_result_rx,
2074                &mut ordered,
2075                sink,
2076                options,
2077                &mut emission_state,
2078                true,
2079            )?;
2080        }
2081        flush_ordered_parallel_envelope(
2082            &envelope_job_tx,
2083            &envelope_result_rx,
2084            &mut ordered,
2085            sink,
2086            options,
2087            &mut emission_state,
2088        )?;
2089        drop(envelope_job_tx);
2090        while ordered.next_envelope_result_index < ordered.envelope.envelope_index {
2091            receive_ordered_envelope_result(
2092                &envelope_result_rx,
2093                &mut ordered,
2094                sink,
2095                options,
2096                &mut emission_state,
2097                true,
2098            )?;
2099        }
2100
2101        for handle in frame_handles {
2102            handle
2103                .join()
2104                .map_err(|_| FormatError::WriterInvariant("ordered frame worker panicked"))?;
2105        }
2106        for handle in envelope_handles {
2107            handle
2108                .join()
2109                .map_err(|_| FormatError::WriterInvariant("ordered envelope worker panicked"))?;
2110        }
2111        Ok(())
2112    })?;
2113    let emit_payload = emit_payload_started.elapsed();
2114
2115    emission_state.next_block_index = ordered.next_payload_block_index;
2116    let digest = ordered.hasher.finalize();
2117    let mut content_sha256 = [0u8; 32];
2118    content_sha256.copy_from_slice(&digest);
2119    let payload = PayloadPlanning {
2120        tar_members: ordered.tar_members,
2121        frames: ordered.frames,
2122        payload_objects: ordered.payload_objects,
2123        payload_block_count: ordered.payload_block_count,
2124        tar_total_size: ordered.tar_total_size,
2125        content_sha256,
2126    };
2127    let plan = build_writer_plan_from_payload(
2128        payload,
2129        emission_state.next_block_index,
2130        master_key,
2131        options,
2132        None,
2133        kdf_params,
2134        archive_uuid,
2135        session_id,
2136        root_auth,
2137    )?;
2138    if plan.options != options || plan.crypto_header != crypto_header {
2139        return Err(FormatError::WriterUnsupported(
2140            "ordered parallel metadata exceeded the predeclared header class",
2141        )
2142        .into());
2143    }
2144    let mut summary = emit_writer_plan_suffix(
2145        &subkeys,
2146        root_auth,
2147        authenticator,
2148        plan,
2149        sink,
2150        emission_state,
2151    )?;
2152    summary.timings.emit_payload += emit_payload;
2153    summary.timings.total = total_started.elapsed();
2154    Ok(summary)
2155}
2156
2157pub(crate) struct OrderedParallelArchiveWriter<'a, O: ArchiveWriteSink> {
2158    frame_job_tx: &'a std::sync::mpsc::SyncSender<OrderedFrameJob>,
2159    frame_result_rx: &'a std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2160    envelope_job_tx: &'a std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2161    envelope_result_rx:
2162        &'a std::sync::mpsc::Receiver<Result<OrderedEnvelopeResult, ArchiveWriteError>>,
2163    ordered: &'a mut OrderedParallelState,
2164    sink: &'a mut O,
2165    options: WriterOptions,
2166    emission_state: &'a mut WriterEmissionState,
2167}
2168
2169impl<O: ArchiveWriteSink> OrderedParallelArchiveWriter<'_, O> {
2170    pub(crate) fn write_regular_member_from_reader(
2171        &mut self,
2172        member: StreamingRegularMember,
2173        payload: &mut dyn Read,
2174    ) -> Result<(), ArchiveWriteError> {
2175        let path = member.archive_path;
2176        validate_file_path_bytes(&path, self.options.max_path_length)?;
2177        let prefix = build_regular_file_member_prefix(
2178            &path,
2179            member.file_data_size,
2180            member.mode,
2181            member.mtime,
2182        )?;
2183        let member_start = self.ordered.tar_total_size;
2184        let member_group_size = checked_u64_add(
2185            prefix.len() as u64,
2186            checked_u64_add(
2187                member.file_data_size,
2188                padding_to_512_u64(member.file_data_size),
2189                "tar member",
2190            )?,
2191            "tar member",
2192        )?;
2193        let member_index = self.ordered.tar_members.len();
2194        self.ordered.tar_members.push(TarMember {
2195            path,
2196            tar_member_group_start: member_start,
2197            tar_member_group_size: member_group_size,
2198            file_data_size: member.file_data_size,
2199            mode: member.mode,
2200            mtime: member.mtime,
2201        });
2202
2203        let mut reader =
2204            StreamingMemberReader::new(Box::new(payload), prefix, member.file_data_size);
2205        let mut member_offset = 0u64;
2206        while member_offset < member_group_size {
2207            let remaining = member_group_size - member_offset;
2208            let read_len = remaining.min(self.options.chunk_size as u64);
2209            let mut plaintext = vec![0u8; to_usize_writer(read_len, "payload chunk")?];
2210            reader
2211                .read_exact(&mut plaintext)
2212                .map_err(ArchiveWriteError::Io)?;
2213            self.ordered.hasher.update(&plaintext);
2214            let frame_index = self.ordered.next_frame_job_index;
2215            self.ordered.next_frame_job_index = checked_u64_add(
2216                self.ordered.next_frame_job_index,
2217                1,
2218                "PayloadFrame.frame_index",
2219            )?;
2220            send_ordered_frame_job(
2221                OrderedFrameJob {
2222                    frame_index,
2223                    member_index,
2224                    member_start,
2225                    member_offset,
2226                    member_group_size,
2227                    plaintext,
2228                },
2229                self.frame_job_tx,
2230                self.frame_result_rx,
2231                self.envelope_job_tx,
2232                self.envelope_result_rx,
2233                self.ordered,
2234                self.sink,
2235                self.options,
2236                self.emission_state,
2237            )?;
2238            member_offset = checked_u64_add(member_offset, read_len, "payload chunk")?;
2239            self.ordered.tar_total_size =
2240                checked_u64_add(self.ordered.tar_total_size, read_len, "tar stream")?;
2241        }
2242        Ok(())
2243    }
2244}
2245
2246#[allow(clippy::too_many_arguments)]
2247fn send_ordered_frame_job<O: ArchiveWriteSink>(
2248    mut job: OrderedFrameJob,
2249    frame_job_tx: &std::sync::mpsc::SyncSender<OrderedFrameJob>,
2250    frame_result_rx: &std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2251    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2252    envelope_result_rx: &std::sync::mpsc::Receiver<
2253        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2254    >,
2255    ordered: &mut OrderedParallelState,
2256    sink: &mut O,
2257    options: WriterOptions,
2258    emission_state: &mut WriterEmissionState,
2259) -> Result<(), ArchiveWriteError> {
2260    loop {
2261        match frame_job_tx.try_send(job) {
2262            Ok(()) => {
2263                drain_ordered_frame_results(
2264                    frame_result_rx,
2265                    envelope_job_tx,
2266                    envelope_result_rx,
2267                    ordered,
2268                    sink,
2269                    options,
2270                    emission_state,
2271                )?;
2272                return Ok(());
2273            }
2274            Err(std::sync::mpsc::TrySendError::Full(returned)) => {
2275                job = returned;
2276                receive_ordered_frame_result(
2277                    frame_result_rx,
2278                    envelope_job_tx,
2279                    envelope_result_rx,
2280                    ordered,
2281                    sink,
2282                    options,
2283                    emission_state,
2284                    true,
2285                )?;
2286            }
2287            Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
2288                return Err(FormatError::WriterInvariant("ordered frame worker stopped").into());
2289            }
2290        }
2291    }
2292}
2293
2294#[allow(clippy::too_many_arguments)]
2295fn drain_ordered_frame_results<O: ArchiveWriteSink>(
2296    frame_result_rx: &std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2297    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2298    envelope_result_rx: &std::sync::mpsc::Receiver<
2299        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2300    >,
2301    ordered: &mut OrderedParallelState,
2302    sink: &mut O,
2303    options: WriterOptions,
2304    emission_state: &mut WriterEmissionState,
2305) -> Result<(), ArchiveWriteError> {
2306    while receive_ordered_frame_result(
2307        frame_result_rx,
2308        envelope_job_tx,
2309        envelope_result_rx,
2310        ordered,
2311        sink,
2312        options,
2313        emission_state,
2314        false,
2315    )? {}
2316    Ok(())
2317}
2318
2319#[allow(clippy::too_many_arguments)]
2320fn receive_ordered_frame_result<O: ArchiveWriteSink>(
2321    frame_result_rx: &std::sync::mpsc::Receiver<Result<OrderedFrameResult, ArchiveWriteError>>,
2322    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2323    envelope_result_rx: &std::sync::mpsc::Receiver<
2324        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2325    >,
2326    ordered: &mut OrderedParallelState,
2327    sink: &mut O,
2328    options: WriterOptions,
2329    emission_state: &mut WriterEmissionState,
2330    wait: bool,
2331) -> Result<bool, ArchiveWriteError> {
2332    let result = if wait {
2333        match frame_result_rx.recv() {
2334            Ok(result) => result?,
2335            Err(_) => {
2336                return Err(FormatError::WriterInvariant("ordered frame worker stopped").into())
2337            }
2338        }
2339    } else {
2340        match frame_result_rx.try_recv() {
2341            Ok(result) => result?,
2342            Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(false),
2343            Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(false),
2344        }
2345    };
2346    ordered.frame_buffer.insert(result.frame_index, result);
2347    while let Some(result) = ordered
2348        .frame_buffer
2349        .remove(&ordered.next_frame_result_index)
2350    {
2351        append_ordered_frame_result(
2352            result,
2353            envelope_job_tx,
2354            envelope_result_rx,
2355            ordered,
2356            sink,
2357            options,
2358            emission_state,
2359        )?;
2360        ordered.next_frame_result_index = checked_u64_add(
2361            ordered.next_frame_result_index,
2362            1,
2363            "PayloadFrame.frame_index",
2364        )?;
2365    }
2366    Ok(true)
2367}
2368
2369#[allow(clippy::too_many_arguments)]
2370fn append_ordered_frame_result<O: ArchiveWriteSink>(
2371    result: OrderedFrameResult,
2372    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2373    envelope_result_rx: &std::sync::mpsc::Receiver<
2374        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2375    >,
2376    ordered: &mut OrderedParallelState,
2377    sink: &mut O,
2378    options: WriterOptions,
2379    emission_state: &mut WriterEmissionState,
2380) -> Result<(), ArchiveWriteError> {
2381    let next_len = checked_usize_add(
2382        ordered.envelope.plaintext.len(),
2383        result.frame.len(),
2384        "payload",
2385    )?;
2386    if !ordered.envelope.plaintext.is_empty()
2387        && (next_len > options.envelope_target_size as usize
2388            || !payload_object_can_fit(next_len, options)?)
2389    {
2390        flush_ordered_parallel_envelope(
2391            envelope_job_tx,
2392            envelope_result_rx,
2393            ordered,
2394            sink,
2395            options,
2396            emission_state,
2397        )?;
2398    }
2399    if ordered.envelope.plaintext.is_empty()
2400        && !payload_object_can_fit(result.frame.len(), options)?
2401    {
2402        return Err(
2403            FormatError::WriterUnsupported("payload frame exceeds envelope object limits").into(),
2404        );
2405    }
2406    let offset = u32_len(
2407        ordered.envelope.plaintext.len(),
2408        "FrameEntry.offset_in_envelope",
2409    )?;
2410    ordered.envelope.plaintext.extend_from_slice(&result.frame);
2411    let mut flags = 0u32;
2412    if result.member_offset == 0 {
2413        flags |= 0x0000_0001;
2414    }
2415    if checked_u64_add(
2416        result.member_offset,
2417        result.decompressed_size as u64,
2418        "payload chunk",
2419    )? == result.member_group_size
2420    {
2421        flags |= 0x0000_0002;
2422    }
2423    ordered.frames.push(PayloadFrame {
2424        frame_index: ordered.next_frame_metadata_index,
2425        envelope_index: ordered.envelope.envelope_index,
2426        member_index: result.member_index,
2427        offset_in_envelope: offset,
2428        compressed_size: u32_len(result.frame.len(), "FrameEntry.compressed_size")?,
2429        decompressed_size: u32_len(result.decompressed_size, "FrameEntry.decompressed_size")?,
2430        flags,
2431        tar_stream_offset: checked_u64_add(
2432            result.member_start,
2433            result.member_offset,
2434            "PayloadFrame.tar_stream_offset",
2435        )?,
2436    });
2437    ordered.next_frame_metadata_index = checked_u64_add(
2438        ordered.next_frame_metadata_index,
2439        1,
2440        "PayloadFrame.frame_index",
2441    )?;
2442    Ok(())
2443}
2444
2445#[allow(clippy::too_many_arguments)]
2446fn flush_ordered_parallel_envelope<O: ArchiveWriteSink>(
2447    envelope_job_tx: &std::sync::mpsc::SyncSender<OrderedEnvelopeJob>,
2448    envelope_result_rx: &std::sync::mpsc::Receiver<
2449        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2450    >,
2451    ordered: &mut OrderedParallelState,
2452    sink: &mut O,
2453    options: WriterOptions,
2454    emission_state: &mut WriterEmissionState,
2455) -> Result<(), ArchiveWriteError> {
2456    if ordered.envelope.plaintext.is_empty() {
2457        return Ok(());
2458    }
2459    let plaintext_size = u32_len(
2460        ordered.envelope.plaintext.len(),
2461        "EnvelopeEntry.plaintext_size",
2462    )?;
2463    let object_plan = plan_encrypted_object(
2464        ordered.envelope.plaintext.len(),
2465        options.fec_data_shards,
2466        options.fec_parity_shards,
2467        options,
2468    )?;
2469    let extent = ObjectExtent::new(ordered.next_payload_block_index, object_plan)?;
2470    ordered.next_payload_block_index = extent.next_block_index()?;
2471    ordered.payload_block_count = checked_u64_add(
2472        ordered.payload_block_count,
2473        extent.data_block_count as u64,
2474        "payload",
2475    )?;
2476    ordered.payload_objects.push(PayloadObject {
2477        envelope_index: ordered.envelope.envelope_index,
2478        plaintext_size,
2479        object: extent,
2480    });
2481    let mut job = OrderedEnvelopeJob {
2482        envelope_index: ordered.envelope.envelope_index,
2483        plaintext: std::mem::take(&mut ordered.envelope.plaintext),
2484        extent,
2485        collect_data_leaf_hashes: emission_state.data_leaf_hashes.is_some(),
2486    };
2487    ordered.envelope.envelope_index =
2488        checked_u64_add(ordered.envelope.envelope_index, 1, "EnvelopeEntry")?;
2489    loop {
2490        match envelope_job_tx.try_send(job) {
2491            Ok(()) => {
2492                drain_ordered_envelope_results(
2493                    envelope_result_rx,
2494                    ordered,
2495                    sink,
2496                    options,
2497                    emission_state,
2498                )?;
2499                return Ok(());
2500            }
2501            Err(std::sync::mpsc::TrySendError::Full(returned)) => {
2502                job = returned;
2503                receive_ordered_envelope_result(
2504                    envelope_result_rx,
2505                    ordered,
2506                    sink,
2507                    options,
2508                    emission_state,
2509                    true,
2510                )?;
2511            }
2512            Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
2513                return Err(FormatError::WriterInvariant("ordered envelope worker stopped").into());
2514            }
2515        }
2516    }
2517}
2518
2519fn drain_ordered_envelope_results<O: ArchiveWriteSink>(
2520    envelope_result_rx: &std::sync::mpsc::Receiver<
2521        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2522    >,
2523    ordered: &mut OrderedParallelState,
2524    sink: &mut O,
2525    options: WriterOptions,
2526    emission_state: &mut WriterEmissionState,
2527) -> Result<(), ArchiveWriteError> {
2528    while receive_ordered_envelope_result(
2529        envelope_result_rx,
2530        ordered,
2531        sink,
2532        options,
2533        emission_state,
2534        false,
2535    )? {}
2536    Ok(())
2537}
2538
2539fn receive_ordered_envelope_result<O: ArchiveWriteSink>(
2540    envelope_result_rx: &std::sync::mpsc::Receiver<
2541        Result<OrderedEnvelopeResult, ArchiveWriteError>,
2542    >,
2543    ordered: &mut OrderedParallelState,
2544    sink: &mut O,
2545    options: WriterOptions,
2546    emission_state: &mut WriterEmissionState,
2547    wait: bool,
2548) -> Result<bool, ArchiveWriteError> {
2549    let result = if wait {
2550        match envelope_result_rx.recv() {
2551            Ok(result) => result?,
2552            Err(_) => {
2553                return Err(FormatError::WriterInvariant("ordered envelope worker stopped").into());
2554            }
2555        }
2556    } else {
2557        match envelope_result_rx.try_recv() {
2558            Ok(result) => result?,
2559            Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(false),
2560            Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(false),
2561        }
2562    };
2563    ordered
2564        .envelope_buffer
2565        .insert(result.envelope_index, result);
2566    while let Some(result) = ordered
2567        .envelope_buffer
2568        .remove(&ordered.next_envelope_result_index)
2569    {
2570        emit_ordered_envelope_result(result, sink, options, emission_state)?;
2571        ordered.next_envelope_result_index =
2572            checked_u64_add(ordered.next_envelope_result_index, 1, "EnvelopeEntry")?;
2573    }
2574    Ok(true)
2575}
2576
2577fn build_ordered_frame_result(
2578    job: OrderedFrameJob,
2579    options: WriterOptions,
2580) -> Result<OrderedFrameResult, ArchiveWriteError> {
2581    let frame = compress_zstd_frame_with_jobs(&job.plaintext, options.zstd_level, 1)?;
2582    Ok(OrderedFrameResult {
2583        frame_index: job.frame_index,
2584        member_index: job.member_index,
2585        member_start: job.member_start,
2586        member_offset: job.member_offset,
2587        member_group_size: job.member_group_size,
2588        decompressed_size: job.plaintext.len(),
2589        frame,
2590    })
2591}
2592
2593fn build_ordered_envelope_result(
2594    job: OrderedEnvelopeJob,
2595    subkeys: &Subkeys,
2596    options: WriterOptions,
2597    archive_uuid: [u8; 16],
2598    session_id: [u8; 16],
2599) -> Result<OrderedEnvelopeResult, ArchiveWriteError> {
2600    let context = ObjectEncryptionContext {
2601        key: &subkeys.enc_key,
2602        nonce_seed: &subkeys.nonce_seed,
2603        domain: b"envelope",
2604        counter: job.envelope_index,
2605        data_kind: BlockKind::PayloadData,
2606        parity_kind: BlockKind::PayloadParity,
2607        data_shard_max: options.fec_data_shards,
2608        class_parity_shard_max: options.fec_parity_shards,
2609        archive_uuid: &archive_uuid,
2610        session_id: &session_id,
2611    };
2612    if job.extent.parity_block_count == 0 && !job.collect_data_leaf_hashes {
2613        return Ok(OrderedEnvelopeResult {
2614            envelope_index: job.envelope_index,
2615            records: OrderedEnvelopeRecords::Serialized(serialize_zero_parity_encrypted_object(
2616                &job.plaintext,
2617                context,
2618                job.extent,
2619                options,
2620            )?),
2621        });
2622    }
2623
2624    let mut local_next_block_index = job.extent.first_block_index;
2625    let object = encrypt_object(
2626        &job.plaintext,
2627        context,
2628        &mut local_next_block_index,
2629        options,
2630    )?;
2631    validate_planned_extent(&object, job.extent)?;
2632    Ok(OrderedEnvelopeResult {
2633        envelope_index: job.envelope_index,
2634        records: OrderedEnvelopeRecords::Materialized(object.records),
2635    })
2636}
2637
2638fn emit_ordered_envelope_result<O: ArchiveWriteSink>(
2639    result: OrderedEnvelopeResult,
2640    sink: &mut O,
2641    options: WriterOptions,
2642    emission_state: &mut WriterEmissionState,
2643) -> Result<(), ArchiveWriteError> {
2644    match result.records {
2645        OrderedEnvelopeRecords::Materialized(records) => {
2646            for record in &records {
2647                emit_block_record(
2648                    sink,
2649                    options,
2650                    &mut emission_state.bytes_written,
2651                    &mut emission_state.record_counts,
2652                    &mut emission_state.data_leaf_hashes,
2653                    record,
2654                )?;
2655            }
2656        }
2657        OrderedEnvelopeRecords::Serialized(records) => {
2658            for record in &records {
2659                emit_serialized_block_record(
2660                    sink,
2661                    options,
2662                    &mut emission_state.bytes_written,
2663                    &mut emission_state.record_counts,
2664                    record,
2665                )?;
2666            }
2667        }
2668    }
2669    Ok(())
2670}
2671
2672fn validate_single_pass_writer_options(options: WriterOptions) -> Result<(), FormatError> {
2673    if options.volume_loss_tolerance != 0 {
2674        return Err(FormatError::WriterUnsupported(
2675            "streaming create cannot tolerate volume loss",
2676        ));
2677    }
2678    if options.target_volume_size.is_some() {
2679        return Err(FormatError::WriterUnsupported(
2680            "streaming create does not support target volume sizing",
2681        ));
2682    }
2683    Ok(())
2684}
2685
2686fn begin_writer_emission_state<O: ArchiveWriteSink>(
2687    sink: &mut O,
2688    options: WriterOptions,
2689    crypto_header: &[u8],
2690    archive_uuid: [u8; 16],
2691    session_id: [u8; 16],
2692    collect_data_leaf_hashes: bool,
2693) -> Result<WriterEmissionState, ArchiveWriteError> {
2694    let volume_count = usize::try_from(options.stripe_width)
2695        .map_err(|_| FormatError::WriterUnsupported("stripe_width"))?;
2696    sink.begin_archive(volume_count)?;
2697
2698    let mut state = WriterEmissionState {
2699        volume_headers: Vec::with_capacity(volume_count),
2700        bytes_written: vec![0u64; volume_count],
2701        record_counts: vec![0u64; volume_count],
2702        data_leaf_hashes: collect_data_leaf_hashes.then(Vec::new),
2703        next_block_index: 0,
2704    };
2705
2706    for volume_index in 0..volume_count {
2707        let volume_index_u32 = u32::try_from(volume_index)
2708            .map_err(|_| FormatError::WriterUnsupported("volume_index"))?;
2709        let volume_header = VolumeHeader {
2710            format_version: FORMAT_VERSION,
2711            volume_format_rev: VOLUME_FORMAT_REV,
2712            volume_index: volume_index_u32,
2713            stripe_width: options.stripe_width,
2714            archive_uuid,
2715            session_id,
2716            crypto_header_offset: VOLUME_HEADER_LEN as u32,
2717            crypto_header_length: u32_len(crypto_header.len(), "CryptoHeader")?,
2718            header_crc32c: 0,
2719        };
2720        let volume_header_bytes = volume_header.to_bytes();
2721        sink.write_volume(volume_index, &volume_header_bytes)?;
2722        sink.write_volume(volume_index, crypto_header)?;
2723        state.bytes_written[volume_index] = checked_u64_add(
2724            VOLUME_HEADER_LEN as u64,
2725            crypto_header.len() as u64,
2726            "volume header",
2727        )?;
2728        state.volume_headers.push(volume_header_bytes);
2729    }
2730
2731    Ok(state)
2732}
2733
2734fn plan_single_pass_writer_options(options: WriterOptions) -> Result<WriterOptions, FormatError> {
2735    let mut options = plan_writer_options(options)?;
2736    options.index_root_fec_data_shards = max_single_pass_index_root_data_shards(options)?;
2737    plan_writer_options(options)
2738}
2739
2740fn max_single_pass_index_root_data_shards(options: WriterOptions) -> Result<u16, FormatError> {
2741    let block_size_limit = (u32::MAX as u64 / options.block_size as u64).min(u16::MAX as u64);
2742    let mut low = MIN_INDEX_ROOT_FEC_DATA_SHARDS as u64;
2743    let mut high = block_size_limit;
2744    let mut best = low;
2745    while low <= high {
2746        let mid = low + (high - low) / 2;
2747        match compute_parity(mid, options) {
2748            Ok(parity)
2749                if mid + u64::from(parity) <= READER_MAX_INDEX_ROOT_FEC_CLASS_SHARDS as u64 =>
2750            {
2751                best = mid;
2752                low = mid + 1;
2753            }
2754            _ => {
2755                if mid == 0 {
2756                    break;
2757                }
2758                high = mid - 1;
2759            }
2760        }
2761    }
2762    u16::try_from(best).map_err(|_| FormatError::WriterUnsupported("index_root_fec_data_shards"))
2763}
2764
2765impl<O: ArchiveWriteSink> StreamingArchiveWriter<'_, O> {
2766    pub(crate) fn write_regular_member_from_reader(
2767        &mut self,
2768        member: StreamingRegularMember,
2769        payload: &mut dyn Read,
2770    ) -> Result<(), ArchiveWriteError> {
2771        let path = member.archive_path;
2772        validate_file_path_bytes(&path, self.options.max_path_length)?;
2773        let prefix = build_regular_file_member_prefix(
2774            &path,
2775            member.file_data_size,
2776            member.mode,
2777            member.mtime,
2778        )?;
2779        let member_start = self.tar_total_size;
2780        let member_group_size = checked_u64_add(
2781            prefix.len() as u64,
2782            checked_u64_add(
2783                member.file_data_size,
2784                padding_to_512_u64(member.file_data_size),
2785                "tar member",
2786            )?,
2787            "tar member",
2788        )?;
2789        let member_index = self.tar_members.len();
2790        self.tar_members.push(TarMember {
2791            path,
2792            tar_member_group_start: member_start,
2793            tar_member_group_size: member_group_size,
2794            file_data_size: member.file_data_size,
2795            mode: member.mode,
2796            mtime: member.mtime,
2797        });
2798
2799        let mut reader =
2800            StreamingMemberReader::new(Box::new(payload), prefix, member.file_data_size);
2801        let mut member_offset = 0u64;
2802        while member_offset < member_group_size {
2803            let remaining = member_group_size - member_offset;
2804            let max_chunk = remaining.min(self.options.chunk_size as u64);
2805            let mut chunk = vec![0u8; to_usize_writer(max_chunk, "payload chunk")?];
2806            reader
2807                .read_exact(&mut chunk)
2808                .map_err(ArchiveWriteError::Io)?;
2809            let mut chunk_len = chunk.len();
2810            let frame = loop {
2811                let candidate = &chunk[..chunk_len];
2812                let frame = compress_zstd_frame_with_jobs(
2813                    candidate,
2814                    self.options.zstd_level,
2815                    self.options.jobs,
2816                )?;
2817                if payload_object_can_fit(frame.len(), self.options)? {
2818                    break frame;
2819                }
2820                if chunk_len == 1 {
2821                    return Err(FormatError::WriterUnsupported(
2822                        "single-byte payload frame exceeds envelope object limits",
2823                    )
2824                    .into());
2825                }
2826                chunk_len = (chunk_len / 2).max(1);
2827            };
2828            if chunk_len < chunk.len() {
2829                reader.push_back(chunk[chunk_len..].to_vec());
2830            }
2831            let chunk = &chunk[..chunk_len];
2832            self.hasher.update(chunk);
2833            self.append_payload_frame(
2834                &frame,
2835                chunk_len,
2836                member_index,
2837                member_start,
2838                member_offset,
2839                member_group_size,
2840            )?;
2841            member_offset = checked_u64_add(member_offset, chunk_len as u64, "payload chunk")?;
2842            self.tar_total_size =
2843                checked_u64_add(self.tar_total_size, chunk_len as u64, "tar stream")?;
2844        }
2845        Ok(())
2846    }
2847
2848    fn append_payload_frame(
2849        &mut self,
2850        frame: &[u8],
2851        decompressed_size: usize,
2852        member_index: usize,
2853        member_start: u64,
2854        member_offset: u64,
2855        member_group_size: u64,
2856    ) -> Result<(), ArchiveWriteError> {
2857        let next_len = checked_usize_add(self.envelope.plaintext.len(), frame.len(), "payload")?;
2858        if !self.envelope.plaintext.is_empty()
2859            && (next_len > self.options.envelope_target_size as usize
2860                || !payload_object_can_fit(next_len, self.options)?)
2861        {
2862            self.flush_payload_envelope()?;
2863        }
2864        if self.envelope.plaintext.is_empty() && !payload_object_can_fit(frame.len(), self.options)?
2865        {
2866            return Err(FormatError::WriterUnsupported(
2867                "payload frame exceeds envelope object limits",
2868            )
2869            .into());
2870        }
2871        let offset = u32_len(
2872            self.envelope.plaintext.len(),
2873            "FrameEntry.offset_in_envelope",
2874        )?;
2875        self.envelope.plaintext.extend_from_slice(frame);
2876        let mut flags = 0u32;
2877        if member_offset == 0 {
2878            flags |= 0x0000_0001;
2879        }
2880        if checked_u64_add(member_offset, decompressed_size as u64, "payload chunk")?
2881            == member_group_size
2882        {
2883            flags |= 0x0000_0002;
2884        }
2885        self.frames.push(PayloadFrame {
2886            frame_index: self.next_frame_index,
2887            envelope_index: self.envelope.envelope_index,
2888            member_index,
2889            offset_in_envelope: offset,
2890            compressed_size: u32_len(frame.len(), "FrameEntry.compressed_size")?,
2891            decompressed_size: u32_len(decompressed_size, "FrameEntry.decompressed_size")?,
2892            flags,
2893            tar_stream_offset: checked_u64_add(
2894                member_start,
2895                member_offset,
2896                "PayloadFrame.tar_stream_offset",
2897            )?,
2898        });
2899        self.next_frame_index =
2900            checked_u64_add(self.next_frame_index, 1, "PayloadFrame.frame_index")?;
2901        Ok(())
2902    }
2903
2904    fn flush_payload_envelope(&mut self) -> Result<(), ArchiveWriteError> {
2905        if self.envelope.plaintext.is_empty() {
2906            return Ok(());
2907        }
2908        let plaintext_size = u32_len(
2909            self.envelope.plaintext.len(),
2910            "EnvelopeEntry.plaintext_size",
2911        )?;
2912        let object_plan = plan_encrypted_object(
2913            self.envelope.plaintext.len(),
2914            self.options.fec_data_shards,
2915            self.options.fec_parity_shards,
2916            self.options,
2917        )?;
2918        let extent = ObjectExtent::new(self.emission_state.next_block_index, object_plan)?;
2919        let object = encrypt_object(
2920            &self.envelope.plaintext,
2921            ObjectEncryptionContext {
2922                key: &self.subkeys.enc_key,
2923                nonce_seed: &self.subkeys.nonce_seed,
2924                domain: b"envelope",
2925                counter: self.envelope.envelope_index,
2926                data_kind: BlockKind::PayloadData,
2927                parity_kind: BlockKind::PayloadParity,
2928                data_shard_max: self.options.fec_data_shards,
2929                class_parity_shard_max: self.options.fec_parity_shards,
2930                archive_uuid: &self.archive_uuid,
2931                session_id: &self.session_id,
2932            },
2933            &mut self.emission_state.next_block_index,
2934            self.options,
2935        )?;
2936        validate_planned_extent(&object, extent)?;
2937        for record in &object.records {
2938            emit_block_record(
2939                self.sink,
2940                self.options,
2941                &mut self.emission_state.bytes_written,
2942                &mut self.emission_state.record_counts,
2943                &mut self.emission_state.data_leaf_hashes,
2944                record,
2945            )?;
2946        }
2947        self.payload_block_count = checked_u64_add(
2948            self.payload_block_count,
2949            extent.data_block_count as u64,
2950            "payload",
2951        )?;
2952        self.payload_objects.push(PayloadObject {
2953            envelope_index: self.envelope.envelope_index,
2954            plaintext_size,
2955            object: extent,
2956        });
2957        self.envelope.envelope_index =
2958            checked_u64_add(self.envelope.envelope_index, 1, "EnvelopeEntry")?;
2959        self.envelope.plaintext.clear();
2960        Ok(())
2961    }
2962
2963    fn finish(
2964        mut self,
2965        master_key: &MasterKey,
2966        kdf_params: &KdfParams,
2967        root_auth: Option<RootAuthWriterConfig<'_>>,
2968        authenticator: Option<&mut RootAuthAuthenticator<'_>>,
2969    ) -> Result<WrittenArchiveSummary, ArchiveWriteError> {
2970        self.flush_payload_envelope()?;
2971        let digest = self.hasher.finalize();
2972        let mut content_sha256 = [0u8; 32];
2973        content_sha256.copy_from_slice(&digest);
2974        let payload = PayloadPlanning {
2975            tar_members: self.tar_members,
2976            frames: self.frames,
2977            payload_objects: self.payload_objects,
2978            payload_block_count: self.payload_block_count,
2979            tar_total_size: self.tar_total_size,
2980            content_sha256,
2981        };
2982        let plan = build_writer_plan_from_payload(
2983            payload,
2984            self.emission_state.next_block_index,
2985            master_key,
2986            self.options,
2987            None,
2988            kdf_params,
2989            self.archive_uuid,
2990            self.session_id,
2991            root_auth,
2992        )?;
2993        if plan.options != self.options || plan.crypto_header != self.crypto_header {
2994            return Err(FormatError::WriterUnsupported(
2995                "streaming tar stdin metadata exceeded the predeclared header class",
2996            )
2997            .into());
2998        }
2999        emit_writer_plan_suffix(
3000            &self.subkeys,
3001            root_auth,
3002            authenticator,
3003            plan,
3004            self.sink,
3005            self.emission_state,
3006        )
3007    }
3008}
3009
3010struct PayloadFramePlanState<'a> {
3011    envelope: &'a mut PayloadEnvelopeBuilder,
3012    payload_objects: &'a mut Vec<PayloadObject>,
3013    payload_block_count: &'a mut u64,
3014    next_block_index: &'a mut u64,
3015    frames: &'a mut Vec<PayloadFrame>,
3016    next_frame_index: &'a mut u64,
3017    options: WriterOptions,
3018}
3019
3020struct PayloadFramePlanInput<'a> {
3021    frame: &'a [u8],
3022    decompressed_size: usize,
3023    member_index: usize,
3024    member_start: u64,
3025    member_offset: u64,
3026    member_group_size: u64,
3027}
3028
3029fn append_payload_frame_to_plan(
3030    state: PayloadFramePlanState<'_>,
3031    input: PayloadFramePlanInput<'_>,
3032) -> Result<(), FormatError> {
3033    let next_len = checked_usize_add(state.envelope.plaintext.len(), input.frame.len(), "payload")?;
3034    if !state.envelope.plaintext.is_empty()
3035        && (next_len > state.options.envelope_target_size as usize
3036            || !payload_object_can_fit(next_len, state.options)?)
3037    {
3038        flush_payload_envelope_plan(
3039            state.envelope,
3040            state.payload_objects,
3041            state.payload_block_count,
3042            state.next_block_index,
3043            state.options,
3044        )?;
3045    }
3046    if state.envelope.plaintext.is_empty()
3047        && !payload_object_can_fit(input.frame.len(), state.options)?
3048    {
3049        return Err(FormatError::WriterUnsupported(
3050            "payload frame exceeds envelope object limits",
3051        ));
3052    }
3053    let offset = u32_len(
3054        state.envelope.plaintext.len(),
3055        "FrameEntry.offset_in_envelope",
3056    )?;
3057    state.envelope.plaintext.extend_from_slice(input.frame);
3058    let mut flags = 0u32;
3059    if input.member_offset == 0 {
3060        flags |= 0x0000_0001;
3061    }
3062    if checked_u64_add(
3063        input.member_offset,
3064        input.decompressed_size as u64,
3065        "payload chunk",
3066    )? == input.member_group_size
3067    {
3068        flags |= 0x0000_0002;
3069    }
3070    state.frames.push(PayloadFrame {
3071        frame_index: *state.next_frame_index,
3072        envelope_index: state.envelope.envelope_index,
3073        member_index: input.member_index,
3074        offset_in_envelope: offset,
3075        compressed_size: u32_len(input.frame.len(), "FrameEntry.compressed_size")?,
3076        decompressed_size: u32_len(input.decompressed_size, "FrameEntry.decompressed_size")?,
3077        flags,
3078        tar_stream_offset: checked_u64_add(
3079            input.member_start,
3080            input.member_offset,
3081            "PayloadFrame.tar_stream_offset",
3082        )?,
3083    });
3084    *state.next_frame_index =
3085        checked_u64_add(*state.next_frame_index, 1, "PayloadFrame.frame_index")?;
3086    Ok(())
3087}
3088
3089fn flush_payload_envelope_plan(
3090    envelope: &mut PayloadEnvelopeBuilder,
3091    payload_objects: &mut Vec<PayloadObject>,
3092    payload_block_count: &mut u64,
3093    next_block_index: &mut u64,
3094    options: WriterOptions,
3095) -> Result<(), FormatError> {
3096    let plaintext_size = u32_len(envelope.plaintext.len(), "EnvelopeEntry.plaintext_size")?;
3097    let object_plan = plan_encrypted_object(
3098        envelope.plaintext.len(),
3099        options.fec_data_shards,
3100        options.fec_parity_shards,
3101        options,
3102    )?;
3103    let extent = ObjectExtent::new(*next_block_index, object_plan)?;
3104    *next_block_index = extent.next_block_index()?;
3105    *payload_block_count = checked_u64_add(
3106        *payload_block_count,
3107        extent.data_block_count as u64,
3108        "payload",
3109    )?;
3110    payload_objects.push(PayloadObject {
3111        envelope_index: envelope.envelope_index,
3112        plaintext_size,
3113        object: extent,
3114    });
3115    envelope.envelope_index = checked_u64_add(envelope.envelope_index, 1, "EnvelopeEntry")?;
3116    envelope.plaintext.clear();
3117    Ok(())
3118}
3119
3120fn required_stripe_width_for_plan(
3121    plan: &WriterPlan,
3122    master_key: &MasterKey,
3123    target_volume_size: u64,
3124) -> Result<u32, FormatError> {
3125    let subkeys = writer_subkeys(
3126        master_key,
3127        plan.options.aead_algo,
3128        &plan.archive_uuid,
3129        &plan.session_id,
3130    )?;
3131    let mut max_volume_size = 0u64;
3132    let mut max_overhead = 0u64;
3133    let block_record_len = plan.options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
3134    for volume_index in 0..plan.options.stripe_width {
3135        let block_count = striped_block_count(
3136            plan.total_block_count,
3137            plan.options.stripe_width,
3138            volume_index,
3139        );
3140        let volume_size = planned_v41_volume_size(plan, &subkeys, volume_index, block_count)?;
3141        max_volume_size = max_volume_size.max(volume_size);
3142        let record_bytes = checked_u64_mul(block_count, block_record_len, "volume records")?;
3143        let overhead =
3144            volume_size
3145                .checked_sub(record_bytes)
3146                .ok_or(FormatError::WriterInvariant(
3147                    "planned volume record overflow",
3148                ))?;
3149        max_overhead = max_overhead.max(overhead);
3150    }
3151    if max_volume_size <= target_volume_size {
3152        return Ok(plan.options.stripe_width);
3153    }
3154    if target_volume_size <= max_overhead {
3155        return Err(FormatError::WriterUnsupported(
3156            "volume-size is too small for per-volume metadata",
3157        ));
3158    }
3159
3160    let records_per_volume = (target_volume_size - max_overhead) / block_record_len;
3161    if records_per_volume == 0 {
3162        return Err(FormatError::WriterUnsupported(
3163            "volume-size is too small for the configured block-size",
3164        ));
3165    }
3166
3167    let required = ceil_div(plan.total_block_count, records_per_volume)?
3168        .max(plan.options.volume_loss_tolerance as u64 + 1)
3169        .max(1);
3170    u32::try_from(required).map_err(|_| FormatError::WriterUnsupported("volume count"))
3171}
3172
3173fn planned_v41_volume_size(
3174    plan: &WriterPlan,
3175    subkeys: &Subkeys,
3176    volume_index: u32,
3177    block_count: u64,
3178) -> Result<u64, FormatError> {
3179    let volume_header = VolumeHeader {
3180        format_version: FORMAT_VERSION,
3181        volume_format_rev: VOLUME_FORMAT_REV,
3182        volume_index,
3183        stripe_width: plan.options.stripe_width,
3184        archive_uuid: plan.archive_uuid,
3185        session_id: plan.session_id,
3186        crypto_header_offset: VOLUME_HEADER_LEN as u32,
3187        crypto_header_length: u32_len(plan.crypto_header.len(), "CryptoHeader")?,
3188        header_crc32c: 0,
3189    };
3190    let volume_header_bytes = volume_header.to_bytes();
3191    let block_record_len = plan.options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
3192    let block_record_bytes = checked_u64_mul(block_count, block_record_len, "volume records")?;
3193    let manifest_footer_offset = checked_u64_add(
3194        VOLUME_HEADER_LEN as u64 + plan.crypto_header.len() as u64,
3195        block_record_bytes,
3196        "volume records",
3197    )?;
3198    let manifest_footer = build_manifest_footer(
3199        subkeys,
3200        plan.options.aead_algo,
3201        plan.archive_uuid,
3202        plan.session_id,
3203        volume_index,
3204        plan.options.stripe_width,
3205        &plan.index_root_extent,
3206        plan.index_root_plaintext.len(),
3207    )?;
3208    let root_auth_footer = plan
3209        .root_auth_footer_length
3210        .map(|length| vec![0u8; length as usize]);
3211    let root_auth_footer_offset = root_auth_footer
3212        .as_ref()
3213        .map(|_| {
3214            checked_u64_add(
3215                manifest_footer_offset,
3216                MANIFEST_FOOTER_LEN as u64,
3217                "RootAuthFooterV1",
3218            )
3219        })
3220        .transpose()?;
3221    let trailer_offset = checked_u64_add(
3222        manifest_footer_offset,
3223        MANIFEST_FOOTER_LEN as u64 + u64::from(plan.root_auth_footer_length.unwrap_or(0)),
3224        "VolumeTrailer",
3225    )?;
3226    let trailer = build_volume_trailer(VolumeTrailerBuildInput {
3227        subkeys,
3228        aead_algo: plan.options.aead_algo,
3229        archive_uuid: plan.archive_uuid,
3230        session_id: plan.session_id,
3231        volume_index,
3232        block_count,
3233        bytes_written: trailer_offset,
3234        manifest_footer_offset,
3235        closed_at_ns: plan.options.closed_at_ns,
3236        root_auth_footer: root_auth_footer_offset.zip(plan.root_auth_footer_length),
3237    })?;
3238    let cmra_offset = checked_u64_add(trailer_offset, VOLUME_TRAILER_LEN as u64, "CMRA")?;
3239    let cmra = build_v41_cmra(CmraBuildInput {
3240        volume_header_bytes: &volume_header_bytes,
3241        crypto_header: &plan.crypto_header,
3242        block_count,
3243        manifest_footer_offset,
3244        manifest_footer: &manifest_footer,
3245        root_auth_footer_offset,
3246        root_auth_footer: root_auth_footer.as_deref(),
3247        trailer_offset,
3248        trailer: &trailer,
3249        cmra_offset,
3250        options: plan.options,
3251        archive_uuid: plan.archive_uuid,
3252        session_id: plan.session_id,
3253        volume_index,
3254    })?;
3255    checked_u64_add(
3256        checked_u64_add(cmra_offset, cmra.bytes.len() as u64, "CMRA")?,
3257        (CRITICAL_RECOVERY_LOCATOR_LEN * 2) as u64,
3258        "critical recovery locators",
3259    )
3260}
3261
3262fn striped_block_count(total_block_count: u64, stripe_width: u32, volume_index: u32) -> u64 {
3263    let volume_index = volume_index as u64;
3264    let stripe_width = stripe_width as u64;
3265    if total_block_count <= volume_index {
3266        0
3267    } else {
3268        (total_block_count - 1 - volume_index) / stripe_width + 1
3269    }
3270}
3271
3272fn emit_writer_plan<S, O>(
3273    files: &[S],
3274    master_key: &MasterKey,
3275    dictionary: Option<&[u8]>,
3276    root_auth: Option<RootAuthWriterConfig<'_>>,
3277    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
3278    plan: WriterPlan,
3279    sink: &mut O,
3280) -> Result<WrittenArchiveSummary, ArchiveWriteError>
3281where
3282    S: RegularFileSource,
3283    O: ArchiveWriteSink,
3284{
3285    let subkeys = writer_subkeys(
3286        master_key,
3287        plan.options.aead_algo,
3288        &plan.archive_uuid,
3289        &plan.session_id,
3290    )?;
3291    let mut state = begin_writer_emission_state(
3292        sink,
3293        plan.options,
3294        &plan.crypto_header,
3295        plan.archive_uuid,
3296        plan.session_id,
3297        root_auth.is_some(),
3298    )?;
3299
3300    let emit_payload_started = Instant::now();
3301    emit_payload_stream(
3302        files,
3303        dictionary,
3304        &subkeys,
3305        &plan,
3306        &mut state.next_block_index,
3307        sink,
3308        &mut state.bytes_written,
3309        &mut state.record_counts,
3310        &mut state.data_leaf_hashes,
3311    )?;
3312    let emit_payload = emit_payload_started.elapsed();
3313
3314    let mut summary =
3315        emit_writer_plan_suffix(&subkeys, root_auth, authenticator, plan, sink, state)?;
3316    summary.timings.emit_payload += emit_payload;
3317    Ok(summary)
3318}
3319
3320fn emit_writer_plan_suffix<O: ArchiveWriteSink>(
3321    subkeys: &Subkeys,
3322    root_auth: Option<RootAuthWriterConfig<'_>>,
3323    authenticator: Option<&mut RootAuthAuthenticator<'_>>,
3324    plan: WriterPlan,
3325    sink: &mut O,
3326    mut state: WriterEmissionState,
3327) -> Result<WrittenArchiveSummary, ArchiveWriteError> {
3328    let emit_metadata_started = Instant::now();
3329    let volume_count = plan.options.stripe_width as usize;
3330
3331    for planned in &plan.index_shard_objects {
3332        emit_encrypted_object(
3333            &planned.compressed,
3334            &subkeys.index_shard_key,
3335            &subkeys.index_nonce_seed,
3336            b"idxshard",
3337            planned.shard_index,
3338            BlockKind::IndexShardData,
3339            BlockKind::IndexShardParity,
3340            plan.options.index_fec_data_shards,
3341            plan.options.index_fec_parity_shards,
3342            &mut state.next_block_index,
3343            plan.options,
3344            &plan.archive_uuid,
3345            &plan.session_id,
3346            planned.extent,
3347            None,
3348            sink,
3349            &mut state.bytes_written,
3350            &mut state.record_counts,
3351            &mut state.data_leaf_hashes,
3352        )?;
3353    }
3354
3355    let dictionary_records = if let (Some(compressed), Some((extent, _))) =
3356        (plan.compressed_dictionary.as_ref(), plan.dictionary_extent)
3357    {
3358        let object = emit_encrypted_object(
3359            compressed,
3360            &subkeys.dictionary_key,
3361            &subkeys.index_nonce_seed,
3362            b"dict",
3363            0,
3364            BlockKind::DictionaryData,
3365            BlockKind::DictionaryParity,
3366            plan.options.index_root_fec_data_shards,
3367            plan.options.index_root_fec_parity_shards,
3368            &mut state.next_block_index,
3369            plan.options,
3370            &plan.archive_uuid,
3371            &plan.session_id,
3372            extent,
3373            Some(MetadataObjectKind::Dictionary),
3374            sink,
3375            &mut state.bytes_written,
3376            &mut state.record_counts,
3377            &mut state.data_leaf_hashes,
3378        )?;
3379        Some(object.records)
3380    } else {
3381        None
3382    };
3383
3384    for planned in &plan.directory_hint_objects {
3385        emit_encrypted_object(
3386            &planned.compressed,
3387            &subkeys.dir_hint_key,
3388            &subkeys.index_nonce_seed,
3389            b"dirhint",
3390            planned.hint_shard_index,
3391            BlockKind::DirectoryHintData,
3392            BlockKind::DirectoryHintParity,
3393            plan.options.index_fec_data_shards,
3394            plan.options.index_fec_parity_shards,
3395            &mut state.next_block_index,
3396            plan.options,
3397            &plan.archive_uuid,
3398            &plan.session_id,
3399            planned.extent,
3400            None,
3401            sink,
3402            &mut state.bytes_written,
3403            &mut state.record_counts,
3404            &mut state.data_leaf_hashes,
3405        )?;
3406    }
3407
3408    let index_root_object = emit_encrypted_object(
3409        &plan.compressed_index_root,
3410        &subkeys.index_root_key,
3411        &subkeys.index_nonce_seed,
3412        b"idxroot",
3413        0,
3414        BlockKind::IndexRootData,
3415        BlockKind::IndexRootParity,
3416        plan.options.index_root_fec_data_shards,
3417        plan.options.index_root_fec_parity_shards,
3418        &mut state.next_block_index,
3419        plan.options,
3420        &plan.archive_uuid,
3421        &plan.session_id,
3422        plan.index_root_extent,
3423        Some(MetadataObjectKind::IndexRoot),
3424        sink,
3425        &mut state.bytes_written,
3426        &mut state.record_counts,
3427        &mut state.data_leaf_hashes,
3428    )?;
3429    if state.next_block_index != plan.total_block_count {
3430        return Err(FormatError::WriterInvariant("streaming writer block plan mismatch").into());
3431    }
3432
3433    let volume_zero_manifest = build_manifest_footer(
3434        subkeys,
3435        plan.options.aead_algo,
3436        plan.archive_uuid,
3437        plan.session_id,
3438        0,
3439        plan.options.stripe_width,
3440        &plan.index_root_extent,
3441        plan.index_root_plaintext.len(),
3442    )?;
3443    let root_auth_footer = match root_auth {
3444        Some(config) => {
3445            let signer = authenticator.ok_or(FormatError::WriterInvariant(
3446                "missing root-auth authenticator",
3447            ))?;
3448            Some(build_root_auth_footer_from_leaf_hashes(
3449                config,
3450                signer,
3451                RootAuthFooterBuildInput {
3452                    archive_uuid: plan.archive_uuid,
3453                    session_id: plan.session_id,
3454                    options: plan.options,
3455                    crypto_header: &plan.crypto_header,
3456                    volume_zero_manifest: &volume_zero_manifest,
3457                    index_root_plaintext: &plan.index_root_plaintext,
3458                    index_root_extent: plan.index_root_extent,
3459                    dictionary_extent: plan.dictionary_extent,
3460                    shard_entries: &plan.shard_entries,
3461                    payload_objects: &plan.payload_objects,
3462                    directory_hint_entries: &plan.directory_hint_entries,
3463                    data_leaf_hashes: state.data_leaf_hashes.as_deref().ok_or(
3464                        FormatError::WriterInvariant("missing root-auth data leaf hashes"),
3465                    )?,
3466                },
3467            )?)
3468        }
3469        None => None,
3470    };
3471    let root_auth_footer_length = root_auth_footer
3472        .as_ref()
3473        .map(|footer| u32_len(footer.len(), "RootAuthFooterV1"))
3474        .transpose()?;
3475
3476    for volume_index in 0..volume_count {
3477        let volume_index_u32 = u32::try_from(volume_index)
3478            .map_err(|_| FormatError::WriterUnsupported("volume_index"))?;
3479        let manifest_footer_offset = state.bytes_written[volume_index];
3480        let manifest_footer = build_manifest_footer(
3481            subkeys,
3482            plan.options.aead_algo,
3483            plan.archive_uuid,
3484            plan.session_id,
3485            volume_index_u32,
3486            plan.options.stripe_width,
3487            &plan.index_root_extent,
3488            plan.index_root_plaintext.len(),
3489        )?;
3490        sink.write_volume(volume_index, &manifest_footer)?;
3491        state.bytes_written[volume_index] = checked_u64_add(
3492            state.bytes_written[volume_index],
3493            MANIFEST_FOOTER_LEN as u64,
3494            "ManifestFooter",
3495        )?;
3496
3497        let root_auth_footer_offset = if let Some(root_auth_footer) = root_auth_footer.as_ref() {
3498            let offset = state.bytes_written[volume_index];
3499            sink.write_volume(volume_index, root_auth_footer)?;
3500            state.bytes_written[volume_index] = checked_u64_add(
3501                state.bytes_written[volume_index],
3502                root_auth_footer.len() as u64,
3503                "RootAuthFooterV1",
3504            )?;
3505            Some(offset)
3506        } else {
3507            None
3508        };
3509
3510        let trailer_offset = state.bytes_written[volume_index];
3511        let trailer = build_volume_trailer(VolumeTrailerBuildInput {
3512            subkeys,
3513            aead_algo: plan.options.aead_algo,
3514            archive_uuid: plan.archive_uuid,
3515            session_id: plan.session_id,
3516            volume_index: volume_index_u32,
3517            block_count: state.record_counts[volume_index],
3518            bytes_written: trailer_offset,
3519            manifest_footer_offset,
3520            closed_at_ns: plan.options.closed_at_ns,
3521            root_auth_footer: root_auth_footer_offset.zip(root_auth_footer_length),
3522        })?;
3523        sink.write_volume(volume_index, &trailer)?;
3524        state.bytes_written[volume_index] = checked_u64_add(
3525            state.bytes_written[volume_index],
3526            VOLUME_TRAILER_LEN as u64,
3527            "VolumeTrailer",
3528        )?;
3529
3530        let cmra_offset = state.bytes_written[volume_index];
3531        let cmra = build_v41_cmra(CmraBuildInput {
3532            volume_header_bytes: &state.volume_headers[volume_index],
3533            crypto_header: &plan.crypto_header,
3534            block_count: state.record_counts[volume_index],
3535            manifest_footer_offset,
3536            manifest_footer: &manifest_footer,
3537            root_auth_footer_offset,
3538            root_auth_footer: root_auth_footer.as_deref(),
3539            trailer_offset,
3540            trailer: &trailer,
3541            cmra_offset,
3542            options: plan.options,
3543            archive_uuid: plan.archive_uuid,
3544            session_id: plan.session_id,
3545            volume_index: volume_index_u32,
3546        })?;
3547        sink.write_volume(volume_index, &cmra.bytes)?;
3548        state.bytes_written[volume_index] = checked_u64_add(
3549            state.bytes_written[volume_index],
3550            cmra.bytes.len() as u64,
3551            "CMRA",
3552        )?;
3553        let locator_base = CriticalRecoveryLocator {
3554            cmra_offset,
3555            cmra_length: u32_len(cmra.bytes.len(), "CMRA")?,
3556            volume_trailer_offset: trailer_offset,
3557            body_bytes_before_cmra: cmra_offset,
3558            archive_uuid_hint: plan.archive_uuid,
3559            session_id_hint: plan.session_id,
3560            volume_index_hint: volume_index_u32,
3561            locator_sequence: 1,
3562            cmra_shard_size: cmra.shard_size,
3563            cmra_data_shard_count: cmra.data_shard_count,
3564            cmra_parity_shard_count: cmra.parity_shard_count,
3565            cmra_image_length: cmra.image_length,
3566            cmra_image_sha256: cmra.image_sha256,
3567            locator_crc32c: 0,
3568        };
3569        let mirror = locator_base.to_bytes();
3570        sink.write_volume(volume_index, &mirror)?;
3571        let final_locator = CriticalRecoveryLocator {
3572            locator_sequence: 0,
3573            ..locator_base
3574        }
3575        .to_bytes();
3576        sink.write_volume(volume_index, &final_locator)?;
3577        state.bytes_written[volume_index] = checked_u64_add(
3578            state.bytes_written[volume_index],
3579            (CRITICAL_RECOVERY_LOCATOR_LEN * 2) as u64,
3580            "critical recovery locators",
3581        )?;
3582
3583        if volume_index == 0 {
3584            debug_assert_eq!(volume_zero_manifest, manifest_footer);
3585        }
3586    }
3587
3588    let bootstrap_sidecar_bytes = if plan.options.stripe_width == 1 {
3589        let sidecar = build_bootstrap_sidecar(
3590            subkeys,
3591            plan.options.aead_algo,
3592            plan.archive_uuid,
3593            plan.session_id,
3594            &volume_zero_manifest,
3595            &index_root_object.records,
3596            dictionary_records.as_deref(),
3597        )?;
3598        let sidecar_len = sidecar.len() as u64;
3599        sink.write_bootstrap_sidecar(&sidecar)?;
3600        sidecar_len
3601    } else {
3602        0
3603    };
3604
3605    Ok(WrittenArchiveSummary {
3606        volume_count,
3607        archive_bytes: state.bytes_written.iter().sum(),
3608        bootstrap_sidecar_bytes,
3609        archive_uuid: plan.archive_uuid,
3610        session_id: plan.session_id,
3611        timings: WriterTimings {
3612            emit_metadata: emit_metadata_started.elapsed(),
3613            ..WriterTimings::default()
3614        },
3615    })
3616}
3617
3618#[allow(clippy::too_many_arguments)]
3619fn emit_encrypted_object<O: ArchiveWriteSink>(
3620    payload: &[u8],
3621    key: &[u8; 32],
3622    nonce_seed: &[u8; 32],
3623    domain: &[u8],
3624    counter: u64,
3625    data_kind: BlockKind,
3626    parity_kind: BlockKind,
3627    data_shard_max: u16,
3628    class_parity_shard_max: u16,
3629    next_block_index: &mut u64,
3630    options: WriterOptions,
3631    archive_uuid: &[u8; 16],
3632    session_id: &[u8; 16],
3633    expected_extent: ObjectExtent,
3634    metadata_kind: Option<MetadataObjectKind>,
3635    sink: &mut O,
3636    bytes_written: &mut [u64],
3637    record_counts: &mut [u64],
3638    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
3639) -> Result<EncryptedObject, ArchiveWriteError> {
3640    let object = encrypt_object(
3641        payload,
3642        ObjectEncryptionContext {
3643            key,
3644            nonce_seed,
3645            domain,
3646            counter,
3647            data_kind,
3648            parity_kind,
3649            data_shard_max,
3650            class_parity_shard_max,
3651            archive_uuid,
3652            session_id,
3653        },
3654        next_block_index,
3655        options,
3656    )
3657    .map_err(|error| match metadata_kind {
3658        Some(kind) => map_metadata_encrypt_error(error, kind),
3659        None => error,
3660    })?;
3661    validate_planned_extent(&object, expected_extent)?;
3662    for record in &object.records {
3663        emit_block_record(
3664            sink,
3665            options,
3666            bytes_written,
3667            record_counts,
3668            data_leaf_hashes,
3669            record,
3670        )?;
3671    }
3672    Ok(object)
3673}
3674
3675fn emit_block_record<O: ArchiveWriteSink>(
3676    sink: &mut O,
3677    options: WriterOptions,
3678    bytes_written: &mut [u64],
3679    record_counts: &mut [u64],
3680    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
3681    record: &BlockRecord,
3682) -> Result<(), ArchiveWriteError> {
3683    let volume_index = (record.block_index % options.stripe_width as u64) as usize;
3684    let record_bytes = record.to_bytes();
3685    sink.write_volume(volume_index, &record_bytes)?;
3686    bytes_written[volume_index] = checked_u64_add(
3687        bytes_written[volume_index],
3688        record_bytes.len() as u64,
3689        "BlockRecord",
3690    )?;
3691    record_counts[volume_index] =
3692        checked_u64_add(record_counts[volume_index], 1, "BlockRecord count")?;
3693    if let Some(data_leaf_hashes) = data_leaf_hashes.as_mut() {
3694        if record.kind.is_data() {
3695            data_leaf_hashes.push((
3696                record.block_index,
3697                data_block_merkle_leaf_hash(
3698                    record.block_index,
3699                    record.kind,
3700                    record.flags,
3701                    &record.payload,
3702                ),
3703            ));
3704        }
3705    }
3706    Ok(())
3707}
3708
3709fn emit_serialized_block_record<O: ArchiveWriteSink>(
3710    sink: &mut O,
3711    options: WriterOptions,
3712    bytes_written: &mut [u64],
3713    record_counts: &mut [u64],
3714    record: &SerializedBlockRecord,
3715) -> Result<(), ArchiveWriteError> {
3716    let volume_index = (record.block_index % options.stripe_width as u64) as usize;
3717    sink.write_volume(volume_index, &record.bytes)?;
3718    bytes_written[volume_index] = checked_u64_add(
3719        bytes_written[volume_index],
3720        record.bytes.len() as u64,
3721        "BlockRecord",
3722    )?;
3723    record_counts[volume_index] =
3724        checked_u64_add(record_counts[volume_index], 1, "BlockRecord count")?;
3725    Ok(())
3726}
3727
3728#[allow(clippy::too_many_arguments)]
3729fn emit_payload_stream<S, O>(
3730    files: &[S],
3731    dictionary: Option<&[u8]>,
3732    subkeys: &Subkeys,
3733    plan: &WriterPlan,
3734    next_block_index: &mut u64,
3735    sink: &mut O,
3736    bytes_written: &mut [u64],
3737    record_counts: &mut [u64],
3738    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
3739) -> Result<(), ArchiveWriteError>
3740where
3741    S: RegularFileSource,
3742    O: ArchiveWriteSink,
3743{
3744    let mut envelope = PayloadEnvelopeBuilder {
3745        envelope_index: 0,
3746        plaintext: Vec::new(),
3747    };
3748    let mut next_frame_index = 0u64;
3749
3750    for (member_index, file) in files.iter().enumerate() {
3751        let member = plan
3752            .tar_members
3753            .get(member_index)
3754            .ok_or(FormatError::WriterInvariant(
3755                "planned tar member is missing",
3756            ))?;
3757        let current_path =
3758            normalize_lookup_file_path(file.archive_path(), plan.options.max_path_length)?;
3759        if current_path != member.path
3760            || file.file_data_size() != member.file_data_size
3761            || file.mode() != member.mode
3762            || file.mtime() != member.mtime
3763        {
3764            return Err(FormatError::WriterInvariant(
3765                "file source changed between planning and emission",
3766            )
3767            .into());
3768        }
3769        let prefix = build_regular_file_member_prefix(
3770            &member.path,
3771            member.file_data_size,
3772            member.mode,
3773            member.mtime,
3774        )?;
3775        let mut reader = StreamingMemberReader::new(file.open()?, prefix, member.file_data_size);
3776        let mut member_offset = 0u64;
3777        while member_offset < member.tar_member_group_size {
3778            let remaining = member.tar_member_group_size - member_offset;
3779            let max_chunk = remaining.min(plan.options.chunk_size as u64);
3780            let mut chunk = vec![0u8; to_usize_writer(max_chunk, "payload chunk")?];
3781            reader
3782                .read_exact(&mut chunk)
3783                .map_err(ArchiveWriteError::Io)?;
3784            let mut chunk_len = chunk.len();
3785            let frame = loop {
3786                let candidate = &chunk[..chunk_len];
3787                let frame = if let Some(dictionary) = dictionary {
3788                    compress_zstd_frame_with_dictionary_and_jobs(
3789                        candidate,
3790                        plan.options.zstd_level,
3791                        dictionary,
3792                        plan.options.jobs,
3793                    )?
3794                } else {
3795                    compress_zstd_frame_with_jobs(
3796                        candidate,
3797                        plan.options.zstd_level,
3798                        plan.options.jobs,
3799                    )?
3800                };
3801                if payload_object_can_fit(frame.len(), plan.options)? {
3802                    break frame;
3803                }
3804                if chunk_len == 1 {
3805                    return Err(FormatError::WriterUnsupported(
3806                        "single-byte payload frame exceeds envelope object limits",
3807                    )
3808                    .into());
3809                }
3810                chunk_len = (chunk_len / 2).max(1);
3811            };
3812            if chunk_len < chunk.len() {
3813                reader.push_back(chunk[chunk_len..].to_vec());
3814            }
3815            append_payload_frame_to_emit(
3816                &mut envelope,
3817                &frame,
3818                chunk_len,
3819                member_index,
3820                member.tar_member_group_start,
3821                member_offset,
3822                member.tar_member_group_size,
3823                &mut next_frame_index,
3824                subkeys,
3825                plan,
3826                next_block_index,
3827                sink,
3828                bytes_written,
3829                record_counts,
3830                data_leaf_hashes,
3831            )?;
3832            member_offset = checked_u64_add(member_offset, chunk_len as u64, "payload chunk")?;
3833        }
3834    }
3835
3836    if !envelope.plaintext.is_empty() {
3837        flush_payload_envelope_emit(
3838            &mut envelope,
3839            subkeys,
3840            plan,
3841            next_block_index,
3842            sink,
3843            bytes_written,
3844            record_counts,
3845            data_leaf_hashes,
3846        )?;
3847    }
3848    if next_frame_index != plan.frames.len() as u64
3849        || envelope.envelope_index != plan.payload_objects.len() as u64
3850    {
3851        return Err(FormatError::WriterInvariant("streaming payload plan mismatch").into());
3852    }
3853    Ok(())
3854}
3855
3856#[allow(clippy::too_many_arguments)]
3857fn append_payload_frame_to_emit<O: ArchiveWriteSink>(
3858    envelope: &mut PayloadEnvelopeBuilder,
3859    frame: &[u8],
3860    decompressed_size: usize,
3861    member_index: usize,
3862    member_start: u64,
3863    member_offset: u64,
3864    member_group_size: u64,
3865    next_frame_index: &mut u64,
3866    subkeys: &Subkeys,
3867    plan: &WriterPlan,
3868    next_block_index: &mut u64,
3869    sink: &mut O,
3870    bytes_written: &mut [u64],
3871    record_counts: &mut [u64],
3872    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
3873) -> Result<(), ArchiveWriteError> {
3874    let next_len = checked_usize_add(envelope.plaintext.len(), frame.len(), "payload")?;
3875    if !envelope.plaintext.is_empty()
3876        && (next_len > plan.options.envelope_target_size as usize
3877            || !payload_object_can_fit(next_len, plan.options)?)
3878    {
3879        flush_payload_envelope_emit(
3880            envelope,
3881            subkeys,
3882            plan,
3883            next_block_index,
3884            sink,
3885            bytes_written,
3886            record_counts,
3887            data_leaf_hashes,
3888        )?;
3889    }
3890    if envelope.plaintext.is_empty() && !payload_object_can_fit(frame.len(), plan.options)? {
3891        return Err(
3892            FormatError::WriterUnsupported("payload frame exceeds envelope object limits").into(),
3893        );
3894    }
3895    let offset = u32_len(envelope.plaintext.len(), "FrameEntry.offset_in_envelope")?;
3896    let mut flags = 0u32;
3897    if member_offset == 0 {
3898        flags |= 0x0000_0001;
3899    }
3900    if checked_u64_add(member_offset, decompressed_size as u64, "payload chunk")?
3901        == member_group_size
3902    {
3903        flags |= 0x0000_0002;
3904    }
3905    let expected =
3906        plan.frames
3907            .get(*next_frame_index as usize)
3908            .ok_or(FormatError::WriterInvariant(
3909                "planned payload frame is missing",
3910            ))?;
3911    let tar_stream_offset = checked_u64_add(
3912        member_start,
3913        member_offset,
3914        "PayloadFrame.tar_stream_offset",
3915    )?;
3916    if expected.envelope_index != envelope.envelope_index
3917        || expected.member_index != member_index
3918        || expected.offset_in_envelope != offset
3919        || expected.compressed_size != u32_len(frame.len(), "FrameEntry.compressed_size")?
3920        || expected.decompressed_size != u32_len(decompressed_size, "FrameEntry.decompressed_size")?
3921        || expected.flags != flags
3922        || expected.tar_stream_offset != tar_stream_offset
3923    {
3924        return Err(
3925            FormatError::WriterInvariant("emitted payload frame does not match plan").into(),
3926        );
3927    }
3928    envelope.plaintext.extend_from_slice(frame);
3929    *next_frame_index = checked_u64_add(*next_frame_index, 1, "PayloadFrame.frame_index")?;
3930    Ok(())
3931}
3932
3933#[allow(clippy::too_many_arguments)]
3934fn flush_payload_envelope_emit<O: ArchiveWriteSink>(
3935    envelope: &mut PayloadEnvelopeBuilder,
3936    subkeys: &Subkeys,
3937    plan: &WriterPlan,
3938    next_block_index: &mut u64,
3939    sink: &mut O,
3940    bytes_written: &mut [u64],
3941    record_counts: &mut [u64],
3942    data_leaf_hashes: &mut Option<Vec<(u64, [u8; 32])>>,
3943) -> Result<(), ArchiveWriteError> {
3944    let expected = plan
3945        .payload_objects
3946        .get(envelope.envelope_index as usize)
3947        .ok_or(FormatError::WriterInvariant(
3948            "planned payload envelope is missing",
3949        ))?;
3950    if expected.envelope_index != envelope.envelope_index
3951        || expected.plaintext_size
3952            != u32_len(envelope.plaintext.len(), "EnvelopeEntry.plaintext_size")?
3953    {
3954        return Err(
3955            FormatError::WriterInvariant("emitted payload envelope does not match plan").into(),
3956        );
3957    }
3958    emit_encrypted_object(
3959        &envelope.plaintext,
3960        &subkeys.enc_key,
3961        &subkeys.nonce_seed,
3962        b"envelope",
3963        envelope.envelope_index,
3964        BlockKind::PayloadData,
3965        BlockKind::PayloadParity,
3966        plan.options.fec_data_shards,
3967        plan.options.fec_parity_shards,
3968        next_block_index,
3969        plan.options,
3970        &plan.archive_uuid,
3971        &plan.session_id,
3972        expected.object,
3973        None,
3974        sink,
3975        bytes_written,
3976        record_counts,
3977        data_leaf_hashes,
3978    )?;
3979    envelope.envelope_index = checked_u64_add(envelope.envelope_index, 1, "EnvelopeEntry")?;
3980    envelope.plaintext.clear();
3981    Ok(())
3982}
3983
3984pub fn write_empty_archive(master_key: &MasterKey) -> Result<WrittenArchive, FormatError> {
3985    write_archive(&[], master_key, WriterOptions::default())
3986}
3987
3988fn plan_writer_options(mut options: WriterOptions) -> Result<WriterOptions, FormatError> {
3989    if options.jobs == 0 {
3990        return Err(FormatError::WriterUnsupported("jobs must be at least 1"));
3991    }
3992    if options.block_size < MIN_BLOCK_SIZE || options.block_size % 2 != 0 {
3993        return Err(FormatError::WriterUnsupported(
3994            "writer requires an even block size of at least 4096",
3995        ));
3996    }
3997    if options.stripe_width == 0 {
3998        return Err(FormatError::WriterUnsupported(
3999            "stripe_width must be non-zero",
4000        ));
4001    }
4002    if options.volume_loss_tolerance as u32 >= options.stripe_width {
4003        return Err(FormatError::WriterUnsupported(
4004            "volume_loss_tolerance must be less than stripe_width",
4005        ));
4006    }
4007    if options.stripe_width == 1 && options.volume_loss_tolerance != 0 {
4008        return Err(FormatError::WriterUnsupported(
4009            "single-volume archives cannot tolerate volume loss",
4010        ));
4011    }
4012    if matches!(options.target_volume_size, Some(0)) {
4013        return Err(FormatError::WriterUnsupported(
4014            "target_volume_size must be non-zero",
4015        ));
4016    }
4017    if options.bit_rot_buffer_pct > 100 {
4018        return Err(FormatError::WriterUnsupported(
4019            "bit_rot_buffer_pct must be at most 100",
4020        ));
4021    }
4022    if options.chunk_size == 0 || options.chunk_size > options.envelope_target_size {
4023        return Err(FormatError::WriterUnsupported(
4024            "chunk_size must be non-zero and no larger than envelope_target_size",
4025        ));
4026    }
4027    if options.fec_data_shards == 0
4028        || options.index_fec_data_shards == 0
4029        || options.index_root_fec_data_shards == 0
4030    {
4031        return Err(FormatError::WriterUnsupported(
4032            "FEC data shard class maxima must be non-zero",
4033        ));
4034    }
4035    options.index_root_fec_data_shards = options
4036        .index_root_fec_data_shards
4037        .max(MIN_INDEX_ROOT_FEC_DATA_SHARDS);
4038    options.fec_parity_shards =
4039        compute_parity_u16(options.fec_data_shards as u64, options, "fec_parity_shards")?;
4040    options.index_fec_parity_shards = compute_parity_u16(
4041        options.index_fec_data_shards as u64,
4042        options,
4043        "index_fec_parity_shards",
4044    )?;
4045    options.index_root_fec_parity_shards = compute_parity_u16(
4046        options.index_root_fec_data_shards as u64,
4047        options,
4048        "index_root_fec_parity_shards",
4049    )?;
4050    validate_writer_options_match_reader_caps(options)?;
4051    Ok(options)
4052}
4053
4054fn validate_writer_options_match_reader_caps(options: WriterOptions) -> Result<(), FormatError> {
4055    CryptoHeaderFixed {
4056        length: CRYPTO_HEADER_FIXED_LEN as u32,
4057        compression_algo: CompressionAlgo::ZstdFramed,
4058        aead_algo: options.aead_algo,
4059        fec_algo: FecAlgo::ReedSolomonGF16,
4060        kdf_algo: if options.aead_algo.is_encrypted() {
4061            KdfAlgo::Raw
4062        } else {
4063            KdfAlgo::None
4064        },
4065        chunk_size: options.chunk_size,
4066        envelope_target_size: options.envelope_target_size,
4067        block_size: options.block_size,
4068        fec_data_shards: options.fec_data_shards,
4069        fec_parity_shards: options.fec_parity_shards,
4070        index_fec_data_shards: options.index_fec_data_shards,
4071        index_fec_parity_shards: options.index_fec_parity_shards,
4072        index_root_fec_data_shards: options.index_root_fec_data_shards,
4073        index_root_fec_parity_shards: options.index_root_fec_parity_shards,
4074        stripe_width: options.stripe_width,
4075        volume_loss_tolerance: options.volume_loss_tolerance,
4076        bit_rot_buffer_pct: options.bit_rot_buffer_pct,
4077        has_dictionary: 0,
4078        max_path_length: options.max_path_length,
4079        expected_volume_size: options.target_volume_size.unwrap_or(0),
4080    }
4081    .validate_supported_profile()
4082}
4083
4084fn build_crypto_header(
4085    options: WriterOptions,
4086    has_dictionary: bool,
4087    subkeys: &Subkeys,
4088    archive_uuid: &[u8; 16],
4089    session_id: &[u8; 16],
4090    kdf_params: &KdfParams,
4091) -> Result<Vec<u8>, FormatError> {
4092    let kdf_payload = serialize_kdf_params(kdf_params)?;
4093    let length = CRYPTO_HEADER_FIXED_LEN
4094        .checked_add(kdf_payload.len())
4095        .and_then(|value| value.checked_add(CRYPTO_EXTENSION_HEADER_LEN))
4096        .and_then(|value| value.checked_add(CRYPTO_HEADER_HMAC_LEN))
4097        .ok_or(FormatError::WriterUnsupported(
4098            "CryptoHeader length overflow",
4099        ))?;
4100    let kdf_algo = match kdf_params {
4101        KdfParams::None => KdfAlgo::None,
4102        KdfParams::Raw => KdfAlgo::Raw,
4103        KdfParams::Argon2id { .. } => KdfAlgo::Argon2id,
4104    };
4105    match (options.aead_algo, kdf_algo) {
4106        (AeadAlgo::None, KdfAlgo::None) => {}
4107        (aead_algo, KdfAlgo::Raw | KdfAlgo::Argon2id) if aead_algo.is_encrypted() => {}
4108        _ => {
4109            return Err(FormatError::InvalidProtectionMode {
4110                aead_algo: options.aead_algo,
4111                kdf_algo,
4112            });
4113        }
4114    }
4115    let fixed = CryptoHeaderFixed {
4116        length: length as u32,
4117        compression_algo: CompressionAlgo::ZstdFramed,
4118        aead_algo: options.aead_algo,
4119        fec_algo: FecAlgo::ReedSolomonGF16,
4120        kdf_algo,
4121        chunk_size: options.chunk_size,
4122        envelope_target_size: options.envelope_target_size,
4123        block_size: options.block_size,
4124        fec_data_shards: options.fec_data_shards,
4125        fec_parity_shards: options.fec_parity_shards,
4126        index_fec_data_shards: options.index_fec_data_shards,
4127        index_fec_parity_shards: options.index_fec_parity_shards,
4128        index_root_fec_data_shards: options.index_root_fec_data_shards,
4129        index_root_fec_parity_shards: options.index_root_fec_parity_shards,
4130        stripe_width: options.stripe_width,
4131        volume_loss_tolerance: options.volume_loss_tolerance,
4132        bit_rot_buffer_pct: options.bit_rot_buffer_pct,
4133        has_dictionary: if has_dictionary { 1 } else { 0 },
4134        max_path_length: options.max_path_length,
4135        expected_volume_size: options.target_volume_size.unwrap_or(0),
4136    };
4137
4138    let mut bytes = fixed.to_bytes().to_vec();
4139    bytes.extend_from_slice(&kdf_payload);
4140    bytes.extend_from_slice(&0u16.to_le_bytes());
4141    bytes.extend_from_slice(&0u32.to_le_bytes());
4142    let hmac = compute_integrity_tag(
4143        HmacDomain::CryptoHeader,
4144        options.aead_algo,
4145        Some(&subkeys.mac_key),
4146        archive_uuid,
4147        session_id,
4148        &bytes,
4149    )?;
4150    bytes.extend_from_slice(&hmac);
4151    Ok(bytes)
4152}
4153
4154fn serialize_kdf_params(params: &KdfParams) -> Result<Vec<u8>, FormatError> {
4155    let mut bytes = Vec::new();
4156    match params {
4157        KdfParams::None => {
4158            bytes.extend_from_slice(&(KdfAlgo::None as u16).to_le_bytes());
4159        }
4160        KdfParams::Raw => {
4161            bytes.extend_from_slice(&(KdfAlgo::Raw as u16).to_le_bytes());
4162        }
4163        KdfParams::Argon2id {
4164            t_cost,
4165            m_cost_kib,
4166            parallelism,
4167            salt,
4168        } => {
4169            if *t_cost == 0 {
4170                return Err(FormatError::InvalidKdfParams("t_cost must be non-zero"));
4171            }
4172            if *parallelism == 0 {
4173                return Err(FormatError::InvalidKdfParams(
4174                    "parallelism must be non-zero",
4175                ));
4176            }
4177            let min_memory = parallelism
4178                .checked_mul(8)
4179                .ok_or(FormatError::InvalidKdfParams(
4180                    "m_cost_kib requirement overflow",
4181                ))?;
4182            if *m_cost_kib < min_memory {
4183                return Err(FormatError::InvalidKdfParams(
4184                    "m_cost_kib must be at least 8 * parallelism",
4185                ));
4186            }
4187            if !(8..=64).contains(&salt.len()) {
4188                return Err(FormatError::InvalidKdfParams(
4189                    "argon2id salt length must be 8..64",
4190                ));
4191            }
4192            let salt_len = u16::try_from(salt.len())
4193                .map_err(|_| FormatError::InvalidKdfParams("argon2id salt too long"))?;
4194            bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
4195            bytes.extend_from_slice(&t_cost.to_le_bytes());
4196            bytes.extend_from_slice(&m_cost_kib.to_le_bytes());
4197            bytes.extend_from_slice(&parallelism.to_le_bytes());
4198            bytes.extend_from_slice(&salt_len.to_le_bytes());
4199            bytes.extend_from_slice(salt);
4200        }
4201    }
4202    Ok(bytes)
4203}
4204
4205#[cfg(test)]
4206fn build_tar_stream(
4207    files: &[RegularFile<'_>],
4208    max_path_length: u32,
4209) -> Result<(Vec<u8>, Vec<TarMember>), FormatError> {
4210    let mut stream = Vec::new();
4211    let mut members = Vec::with_capacity(files.len());
4212    for file in files {
4213        let path = normalize_lookup_file_path(file.path, max_path_length)?;
4214        let start = stream.len() as u64;
4215        let member_group =
4216            build_regular_file_member_group(&path, file.contents, file.mode, file.mtime)?;
4217        stream.extend_from_slice(&member_group);
4218        members.push(TarMember {
4219            path,
4220            tar_member_group_start: start,
4221            tar_member_group_size: member_group.len() as u64,
4222            file_data_size: file.contents.len() as u64,
4223            mode: file.mode,
4224            mtime: file.mtime,
4225        });
4226    }
4227    Ok((stream, members))
4228}
4229
4230#[cfg(test)]
4231fn build_payload_envelopes(
4232    tar_stream: &[u8],
4233    members: &[TarMember],
4234    options: WriterOptions,
4235    dictionary: Option<&[u8]>,
4236) -> Result<(Vec<PayloadEnvelope>, Vec<PayloadFrame>), FormatError> {
4237    let chunk_size = options.chunk_size as usize;
4238    if chunk_size == 0 {
4239        return Err(FormatError::WriterUnsupported(
4240            "chunk_size must be non-zero and no larger than envelope_target_size",
4241        ));
4242    }
4243    let envelope_target_size = options.envelope_target_size as usize;
4244    let mut envelopes = Vec::new();
4245    let mut current = PayloadEnvelope {
4246        envelope_index: 0,
4247        plaintext: Vec::new(),
4248    };
4249    let mut frames = Vec::new();
4250    let mut next_frame_index = 0u64;
4251
4252    for (member_index, member) in members.iter().enumerate() {
4253        let start = member.tar_member_group_start as usize;
4254        let end = checked_usize_add(start, member.tar_member_group_size as usize, "tar member")?;
4255        let member_bytes = tar_stream
4256            .get(start..end)
4257            .ok_or(FormatError::WriterInvariant(
4258                "tar member range is out of bounds",
4259            ))?;
4260        let mut member_offset = 0usize;
4261        while member_offset < member_bytes.len() {
4262            let mut chunk_len = (member_bytes.len() - member_offset).min(chunk_size);
4263            let frame = loop {
4264                let end = checked_usize_add(member_offset, chunk_len, "payload chunk")?;
4265                let chunk = &member_bytes[member_offset..end];
4266                let frame = if let Some(dictionary) = dictionary {
4267                    compress_zstd_frame_with_dictionary_and_jobs(
4268                        chunk,
4269                        options.zstd_level,
4270                        dictionary,
4271                        options.jobs,
4272                    )?
4273                } else {
4274                    compress_zstd_frame_with_jobs(chunk, options.zstd_level, options.jobs)?
4275                };
4276                if payload_object_can_fit(frame.len(), options)? {
4277                    break frame;
4278                }
4279                if chunk_len == 1 {
4280                    return Err(FormatError::WriterUnsupported(
4281                        "single-byte payload frame exceeds envelope object limits",
4282                    ));
4283                }
4284                chunk_len = (chunk_len / 2).max(1);
4285            };
4286            let next_len = checked_usize_add(current.plaintext.len(), frame.len(), "payload")?;
4287            if !current.plaintext.is_empty()
4288                && (next_len > envelope_target_size || !payload_object_can_fit(next_len, options)?)
4289            {
4290                envelopes.push(current);
4291                current = PayloadEnvelope {
4292                    envelope_index: envelopes.len() as u64,
4293                    plaintext: Vec::new(),
4294                };
4295            }
4296
4297            if current.plaintext.is_empty() && !payload_object_can_fit(frame.len(), options)? {
4298                return Err(FormatError::WriterUnsupported(
4299                    "payload frame exceeds envelope object limits",
4300                ));
4301            }
4302            let offset = u32_len(current.plaintext.len(), "FrameEntry.offset_in_envelope")?;
4303            current.plaintext.extend_from_slice(&frame);
4304            let is_first_member_frame = member_offset == 0;
4305            let is_last_member_frame =
4306                checked_usize_add(member_offset, chunk_len, "payload chunk")? == member_bytes.len();
4307            let mut flags = 0u32;
4308            if is_first_member_frame {
4309                flags |= 0x0000_0001;
4310            }
4311            if is_last_member_frame {
4312                flags |= 0x0000_0002;
4313            }
4314            frames.push(PayloadFrame {
4315                frame_index: next_frame_index,
4316                envelope_index: current.envelope_index,
4317                member_index,
4318                offset_in_envelope: offset,
4319                compressed_size: u32_len(frame.len(), "FrameEntry.compressed_size")?,
4320                decompressed_size: u32_len(chunk_len, "FrameEntry.decompressed_size")?,
4321                flags,
4322                tar_stream_offset: checked_u64_add(
4323                    member.tar_member_group_start,
4324                    u64::try_from(member_offset)
4325                        .map_err(|_| FormatError::WriterUnsupported("chunk offset"))?,
4326                    "PayloadFrame.tar_stream_offset",
4327                )?,
4328            });
4329            next_frame_index = checked_u64_add(next_frame_index, 1, "PayloadFrame.frame_index")?;
4330            member_offset = checked_usize_add(member_offset, chunk_len, "payload chunk")?;
4331        }
4332    }
4333    if !current.plaintext.is_empty() {
4334        envelopes.push(current);
4335    }
4336    Ok((envelopes, frames))
4337}
4338
4339fn sorted_file_rows(members: &[TarMember]) -> Vec<FileRow> {
4340    let mut rows = members
4341        .iter()
4342        .enumerate()
4343        .map(|(member_index, member)| FileRow {
4344            path_hash: hash_prefix(&member.path),
4345            path: member.path.clone(),
4346            member_index,
4347            member: member.clone(),
4348        })
4349        .collect::<Vec<_>>();
4350    rows.sort_by(|left, right| {
4351        (
4352            left.path_hash,
4353            left.path.as_slice(),
4354            left.member.tar_member_group_start,
4355        )
4356            .cmp(&(
4357                right.path_hash,
4358                right.path.as_slice(),
4359                right.member.tar_member_group_start,
4360            ))
4361    });
4362    rows
4363}
4364
4365fn partition_file_rows(rows: Vec<FileRow>) -> Result<Vec<Vec<FileRow>>, FormatError> {
4366    let mut shards = Vec::new();
4367    let mut start = 0usize;
4368    while start < rows.len() {
4369        let mut end = (start + DEFAULT_FILES_PER_INDEX_SHARD).min(rows.len());
4370        if end < rows.len() && rows[end - 1].path_hash == rows[end].path_hash {
4371            let boundary_hash = rows[end].path_hash;
4372            let mut run_start_in_shard = end - 1;
4373            while run_start_in_shard > start
4374                && rows[run_start_in_shard - 1].path_hash == boundary_hash
4375            {
4376                run_start_in_shard -= 1;
4377            }
4378            let mut full_run_start = run_start_in_shard;
4379            while full_run_start > 0 && rows[full_run_start - 1].path_hash == boundary_hash {
4380                full_run_start -= 1;
4381            }
4382            let mut full_run_end = end + 1;
4383            while full_run_end < rows.len() && rows[full_run_end].path_hash == boundary_hash {
4384                full_run_end += 1;
4385            }
4386            let full_run_len = full_run_end - full_run_start;
4387            end = if full_run_len <= MAX_HASH_PREFIX_RUN_FILES {
4388                full_run_end
4389            } else {
4390                (run_start_in_shard + MAX_HASH_PREFIX_RUN_FILES).min(full_run_end)
4391            };
4392        }
4393        if end - start > MAX_FILES_PER_INDEX_SHARD {
4394            return Err(FormatError::WriterUnsupported(
4395                "hash-prefix collision run exceeds max_files_per_index_shard",
4396            ));
4397        }
4398        shards.push(rows[start..end].to_vec());
4399        start = end;
4400    }
4401    Ok(shards)
4402}
4403
4404fn build_index_shard_plaintexts(
4405    shard_rows: &[Vec<FileRow>],
4406    frames: &[PayloadFrame],
4407    payloads: &[PayloadObject],
4408    options: WriterOptions,
4409) -> Result<Vec<PlannedIndexShard>, FormatError> {
4410    let mut planned = Vec::new();
4411    for rows in shard_rows {
4412        append_index_shards_for_rows(&mut planned, rows.clone(), frames, payloads, options)?;
4413    }
4414    Ok(planned)
4415}
4416
4417fn append_index_shards_for_rows(
4418    planned: &mut Vec<PlannedIndexShard>,
4419    rows: Vec<FileRow>,
4420    frames: &[PayloadFrame],
4421    payloads: &[PayloadObject],
4422    options: WriterOptions,
4423) -> Result<(), FormatError> {
4424    let shard_index =
4425        u64::try_from(planned.len()).map_err(|_| FormatError::WriterUnsupported("shard_index"))?;
4426    let candidate = build_index_shard_plaintext(shard_index, &rows, frames, payloads, options)?;
4427    let compressed =
4428        compress_zstd_frame_with_jobs(&candidate.plaintext, options.zstd_level, options.jobs)?;
4429    if index_object_can_fit(compressed.len(), options)? {
4430        planned.push(candidate);
4431        return Ok(());
4432    }
4433    if rows.len() == 1 {
4434        return Err(FormatError::WriterUnsupported(
4435            "single-file IndexShard exceeds index object limits",
4436        ));
4437    }
4438    let split_at = split_sorted_file_rows_for_object_limit(&rows);
4439    append_index_shards_for_rows(
4440        planned,
4441        rows[..split_at].to_vec(),
4442        frames,
4443        payloads,
4444        options,
4445    )?;
4446    append_index_shards_for_rows(
4447        planned,
4448        rows[split_at..].to_vec(),
4449        frames,
4450        payloads,
4451        options,
4452    )
4453}
4454
4455fn split_sorted_file_rows_for_object_limit(rows: &[FileRow]) -> usize {
4456    let midpoint = rows.len() / 2;
4457    if rows[midpoint - 1].path_hash != rows[midpoint].path_hash {
4458        return midpoint;
4459    }
4460
4461    let boundary_hash = rows[midpoint].path_hash;
4462    let mut left = midpoint;
4463    while left > 0 && rows[left - 1].path_hash == boundary_hash {
4464        left -= 1;
4465    }
4466    let mut right = midpoint;
4467    while right < rows.len() && rows[right].path_hash == boundary_hash {
4468        right += 1;
4469    }
4470
4471    match (left > 0, right < rows.len()) {
4472        (true, true) if midpoint - left <= right - midpoint => left,
4473        (true, true) => right,
4474        (true, false) => left,
4475        (false, true) => right,
4476        (false, false) => midpoint,
4477    }
4478}
4479
4480fn build_index_shard_plaintext(
4481    shard_index: u64,
4482    file_rows: &[FileRow],
4483    frames: &[PayloadFrame],
4484    payloads: &[PayloadObject],
4485    options: WriterOptions,
4486) -> Result<PlannedIndexShard, FormatError> {
4487    let mut string_pool = Vec::new();
4488    let mut file_entries = Vec::with_capacity(file_rows.len());
4489    let mut required_frame_indexes = BTreeSet::new();
4490    for row in file_rows {
4491        let path_offset = u32_len(string_pool.len(), "FileEntry.path_offset")?;
4492        string_pool.extend_from_slice(&row.path);
4493        let (first_frame_index, frame_count) = member_frame_range(row.member_index, frames)?;
4494        for offset in 0..frame_count as u64 {
4495            required_frame_indexes.insert(checked_u64_add(
4496                first_frame_index,
4497                offset,
4498                "FileEntry.frame_count",
4499            )?);
4500        }
4501        file_entries.push(FileEntry {
4502            path_hash: row.path_hash,
4503            path_offset,
4504            path_length: u32_len(row.path.len(), "FileEntry.path_length")?,
4505            first_frame_index,
4506            frame_count,
4507            offset_in_first_frame_plaintext: 0,
4508            tar_member_group_size: row.member.tar_member_group_size,
4509            file_data_size: row.member.file_data_size,
4510            flags: 0,
4511        });
4512    }
4513
4514    let frame_entries = frames
4515        .iter()
4516        .filter(|frame| required_frame_indexes.contains(&frame.frame_index))
4517        .map(|frame| FrameEntry {
4518            frame_index: frame.frame_index,
4519            envelope_index: frame.envelope_index,
4520            offset_in_envelope: frame.offset_in_envelope,
4521            compressed_size: frame.compressed_size,
4522            decompressed_size: frame.decompressed_size,
4523            flags: frame.flags,
4524            tar_stream_offset: frame.tar_stream_offset,
4525        })
4526        .collect::<Vec<_>>();
4527    let required_envelope_indexes = frame_entries
4528        .iter()
4529        .map(|frame| frame.envelope_index)
4530        .collect::<BTreeSet<_>>();
4531    let envelope_entries = payloads
4532        .iter()
4533        .filter(|payload| required_envelope_indexes.contains(&payload.envelope_index))
4534        .map(|payload| {
4535            let (first_frame_index, frame_count) =
4536                envelope_frame_range(payload.envelope_index, frames)?;
4537            Ok(EnvelopeEntry {
4538                envelope_index: payload.envelope_index,
4539                first_block_index: payload.object.first_block_index,
4540                data_block_count: payload.object.data_block_count,
4541                parity_block_count: payload.object.parity_block_count,
4542                encrypted_size: payload.object.encrypted_size,
4543                plaintext_size: payload.plaintext_size,
4544                first_frame_index,
4545                frame_count,
4546            })
4547        })
4548        .collect::<Result<Vec<_>, FormatError>>()?;
4549
4550    let plaintext = serialize_index_shard(
4551        shard_index,
4552        &file_entries,
4553        &frame_entries,
4554        &envelope_entries,
4555        &string_pool,
4556        options,
4557    )?;
4558    let first_path_hash = file_rows
4559        .first()
4560        .ok_or(FormatError::WriterInvariant("empty planned IndexShard"))?
4561        .path_hash;
4562    let last_path_hash = file_rows
4563        .last()
4564        .ok_or(FormatError::WriterInvariant("empty planned IndexShard"))?
4565        .path_hash;
4566    Ok(PlannedIndexShard {
4567        shard_index,
4568        plaintext,
4569        file_count: u32_len(file_rows.len(), "IndexShard.file_count")?,
4570        first_path_hash,
4571        last_path_hash,
4572    })
4573}
4574
4575fn serialize_index_shard(
4576    shard_index: u64,
4577    files: &[FileEntry],
4578    frames: &[FrameEntry],
4579    envelopes: &[EnvelopeEntry],
4580    string_pool: &[u8],
4581    _options: WriterOptions,
4582) -> Result<Vec<u8>, FormatError> {
4583    let mut cursor = INDEX_SHARD_HEADER_LEN;
4584    let file_table_offset = table_offset(files.len(), cursor)?;
4585    cursor = checked_usize_add(cursor, files.len() * FILE_ENTRY_LEN, "IndexShard")?;
4586    let frame_table_offset = table_offset(frames.len(), cursor)?;
4587    cursor = checked_usize_add(cursor, frames.len() * FRAME_ENTRY_LEN, "IndexShard")?;
4588    let envelope_table_offset = table_offset(envelopes.len(), cursor)?;
4589    cursor = checked_usize_add(cursor, envelopes.len() * ENVELOPE_ENTRY_LEN, "IndexShard")?;
4590    let string_pool_offset = table_offset(string_pool.len(), cursor)?;
4591
4592    let header = IndexShardHeader {
4593        version: 1,
4594        shard_index,
4595        file_count: u32_len(files.len(), "IndexShard.file_count")?,
4596        frame_count: u32_len(frames.len(), "IndexShard.frame_count")?,
4597        envelope_count: u32_len(envelopes.len(), "IndexShard.envelope_count")?,
4598        file_table_offset,
4599        frame_table_offset,
4600        envelope_table_offset,
4601        string_pool_offset,
4602        string_pool_size: u32_len(string_pool.len(), "IndexShard.string_pool_size")?,
4603    };
4604
4605    let mut bytes = Vec::with_capacity(cursor + string_pool.len());
4606    bytes.extend_from_slice(&header.to_bytes());
4607    for entry in files {
4608        bytes.extend_from_slice(&entry.to_bytes());
4609    }
4610    for entry in frames {
4611        bytes.extend_from_slice(&entry.to_bytes());
4612    }
4613    for entry in envelopes {
4614        bytes.extend_from_slice(&entry.to_bytes());
4615    }
4616    bytes.extend_from_slice(string_pool);
4617    Ok(bytes)
4618}
4619
4620fn build_directory_hint_plaintexts(
4621    shard_rows: &[Vec<FileRow>],
4622    options: WriterOptions,
4623) -> Result<Vec<PlannedDirectoryHintShard>, FormatError> {
4624    let mut map = BTreeMap::<Vec<u8>, BTreeSet<u32>>::new();
4625    for (shard_row_index, rows) in shard_rows.iter().enumerate() {
4626        let shard_row_index = u32::try_from(shard_row_index)
4627            .map_err(|_| FormatError::WriterUnsupported("directory hint shard row index"))?;
4628        for row in rows {
4629            add_directory_hint_rows(&mut map, shard_row_index, &row.path);
4630        }
4631    }
4632
4633    let rows = map
4634        .into_iter()
4635        .map(|(path, shard_rows)| (hash_prefix(&path), path, shard_rows))
4636        .collect::<Vec<_>>();
4637    let mut rows = rows;
4638    rows.sort_by(|left, right| (left.0, left.1.as_slice()).cmp(&(right.0, right.1.as_slice())));
4639
4640    let mut planned = Vec::new();
4641    for chunk in rows.chunks(DEFAULT_DIRECTORY_HINT_ENTRIES_PER_SHARD) {
4642        append_directory_hint_shards_for_rows(&mut planned, chunk.to_vec(), options)?;
4643    }
4644    Ok(planned)
4645}
4646
4647fn append_directory_hint_shards_for_rows(
4648    planned: &mut Vec<PlannedDirectoryHintShard>,
4649    rows: Vec<([u8; 8], Vec<u8>, BTreeSet<u32>)>,
4650    options: WriterOptions,
4651) -> Result<(), FormatError> {
4652    let hint_shard_index = u64::try_from(planned.len())
4653        .map_err(|_| FormatError::WriterUnsupported("hint_shard_index"))?;
4654    let candidate = build_directory_hint_plaintext(hint_shard_index, &rows)?;
4655    let compressed =
4656        compress_zstd_frame_with_jobs(&candidate.plaintext, options.zstd_level, options.jobs)?;
4657    if index_object_can_fit(compressed.len(), options)? {
4658        planned.push(candidate);
4659        return Ok(());
4660    }
4661    if rows.len() == 1 {
4662        return Err(FormatError::WriterUnsupported(
4663            "single DirectoryHintEntry exceeds index object limits",
4664        ));
4665    }
4666    let split_at = rows.len() / 2;
4667    append_directory_hint_shards_for_rows(planned, rows[..split_at].to_vec(), options)?;
4668    append_directory_hint_shards_for_rows(planned, rows[split_at..].to_vec(), options)
4669}
4670
4671fn add_directory_hint_rows(
4672    map: &mut BTreeMap<Vec<u8>, BTreeSet<u32>>,
4673    shard_row_index: u32,
4674    path: &[u8],
4675) {
4676    map.entry(Vec::new()).or_default().insert(shard_row_index);
4677    let mut cursor = 0usize;
4678    while let Some(position) = path[cursor..].iter().position(|byte| *byte == b'/') {
4679        let slash = cursor + position;
4680        if slash > 0 {
4681            map.entry(path[..slash].to_vec())
4682                .or_default()
4683                .insert(shard_row_index);
4684        }
4685        cursor = slash + 1;
4686    }
4687}
4688
4689fn build_directory_hint_plaintext(
4690    hint_shard_index: u64,
4691    rows: &[([u8; 8], Vec<u8>, BTreeSet<u32>)],
4692) -> Result<PlannedDirectoryHintShard, FormatError> {
4693    let mut entries = Vec::with_capacity(rows.len());
4694    let mut shard_row_indexes = Vec::new();
4695    let mut string_pool = Vec::new();
4696
4697    for (dir_hash, path, shard_rows) in rows {
4698        let path_offset = if path.is_empty() {
4699            0
4700        } else {
4701            u64::try_from(string_pool.len())
4702                .map_err(|_| FormatError::WriterUnsupported("DirectoryHintEntry.path_offset"))?
4703        };
4704        if !path.is_empty() {
4705            string_pool.extend_from_slice(path);
4706        }
4707        let shard_list_start_index = u32_len(
4708            shard_row_indexes.len(),
4709            "DirectoryHintEntry.shard_list_start_index",
4710        )?;
4711        shard_row_indexes.extend(shard_rows.iter().copied());
4712        entries.push(DirectoryHintEntry {
4713            dir_hash: *dir_hash,
4714            path_offset,
4715            path_length: u32_len(path.len(), "DirectoryHintEntry.path_length")?,
4716            shard_list_start_index,
4717            shard_count: u32_len(shard_rows.len(), "DirectoryHintEntry.shard_count")?,
4718        });
4719    }
4720
4721    let plaintext = serialize_directory_hint_table(
4722        hint_shard_index,
4723        &entries,
4724        &shard_row_indexes,
4725        &string_pool,
4726    )?;
4727    let first_dir_hash = rows
4728        .first()
4729        .ok_or(FormatError::WriterInvariant("empty directory hint shard"))?
4730        .0;
4731    let last_dir_hash = rows
4732        .last()
4733        .ok_or(FormatError::WriterInvariant("empty directory hint shard"))?
4734        .0;
4735    Ok(PlannedDirectoryHintShard {
4736        hint_shard_index,
4737        plaintext,
4738        entry_count: rows.len() as u64,
4739        first_dir_hash,
4740        last_dir_hash,
4741    })
4742}
4743
4744fn serialize_directory_hint_table(
4745    hint_shard_index: u64,
4746    entries: &[DirectoryHintEntry],
4747    shard_row_indexes: &[u32],
4748    string_pool: &[u8],
4749) -> Result<Vec<u8>, FormatError> {
4750    let entry_table_offset = table_offset(entries.len(), DIRECTORY_HINT_TABLE_LEN)?;
4751    let shard_list_cursor = checked_usize_add(
4752        DIRECTORY_HINT_TABLE_LEN,
4753        entries.len() * DIRECTORY_HINT_ENTRY_LEN,
4754        "DirectoryHintTable",
4755    )?;
4756    let shard_list_offset = table_offset(shard_row_indexes.len(), shard_list_cursor)?;
4757    let string_pool_cursor = checked_usize_add(
4758        shard_list_cursor,
4759        shard_row_indexes.len() * 4,
4760        "DirectoryHintTable",
4761    )?;
4762    let string_pool_offset = if string_pool.is_empty() {
4763        0
4764    } else {
4765        u64::try_from(string_pool_cursor)
4766            .map_err(|_| FormatError::WriterUnsupported("DirectoryHintTable.string_pool_offset"))?
4767    };
4768    let header = DirectoryHintTableHeader {
4769        version: 1,
4770        hint_shard_index,
4771        entry_count: entries.len() as u64,
4772        entry_table_offset: entry_table_offset as u64,
4773        shard_list_offset: shard_list_offset as u64,
4774        string_pool_offset,
4775        string_pool_size: string_pool.len() as u64,
4776    };
4777
4778    let mut bytes = Vec::with_capacity(string_pool_cursor + string_pool.len());
4779    bytes.extend_from_slice(&header.to_bytes());
4780    for entry in entries {
4781        bytes.extend_from_slice(&entry.to_bytes());
4782    }
4783    for row in shard_row_indexes {
4784        bytes.extend_from_slice(&row.to_le_bytes());
4785    }
4786    bytes.extend_from_slice(string_pool);
4787    Ok(bytes)
4788}
4789
4790#[derive(Debug, Clone, Copy)]
4791struct IndexRootPlaintextInput<'a> {
4792    shard_entries: &'a [ShardEntry],
4793    frame_count: u64,
4794    envelope_count: u64,
4795    file_count: u64,
4796    payload_block_count: u64,
4797    tar_total_size: u64,
4798    content_sha256: [u8; 32],
4799    directory_hint_entries: &'a [DirectoryHintShardEntry],
4800    dictionary_extent: Option<(ObjectExtent, u32)>,
4801}
4802
4803fn build_index_root_plaintext(input: IndexRootPlaintextInput<'_>) -> Vec<u8> {
4804    let mut header = IndexRootHeader::empty();
4805    header.frame_count = input.frame_count;
4806    header.envelope_count = input.envelope_count;
4807    header.file_count = input.file_count;
4808    header.payload_block_count = input.payload_block_count;
4809    header.tar_total_size = input.tar_total_size;
4810    header.content_sha256 = input.content_sha256;
4811    if let Some((dictionary, decompressed_size)) = input.dictionary_extent {
4812        header.dictionary_first_block = dictionary.first_block_index;
4813        header.dictionary_data_block_count = dictionary.data_block_count;
4814        header.dictionary_parity_block_count = dictionary.parity_block_count;
4815        header.dictionary_encrypted_size = dictionary.encrypted_size;
4816        header.dictionary_decompressed_size = decompressed_size;
4817    }
4818    let root = IndexRoot {
4819        header,
4820        shards: input.shard_entries.to_vec(),
4821        directory_hint_shards: input.directory_hint_entries.to_vec(),
4822    };
4823    root.to_bytes()
4824}
4825
4826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4827struct MetadataClassPlan {
4828    options: WriterOptions,
4829    index_root: PlannedEncryptedObject,
4830    dictionary: Option<PlannedEncryptedObject>,
4831}
4832
4833fn plan_index_root_metadata_class(
4834    mut options: WriterOptions,
4835    compressed_index_root_len: usize,
4836    compressed_dictionary_len: Option<usize>,
4837) -> Result<MetadataClassPlan, FormatError> {
4838    let index_root = plan_metadata_object_without_class(
4839        compressed_index_root_len,
4840        options,
4841        MetadataObjectKind::IndexRoot,
4842    )?;
4843    let dictionary = compressed_dictionary_len
4844        .map(|len| plan_metadata_object_without_class(len, options, MetadataObjectKind::Dictionary))
4845        .transpose()?;
4846    let required_data_shards = u32::from(options.index_root_fec_data_shards)
4847        .max(MIN_INDEX_ROOT_FEC_DATA_SHARDS as u32)
4848        .max(index_root.data_block_count)
4849        .max(dictionary.map(|plan| plan.data_block_count).unwrap_or(0));
4850    let required_data_shards = u16::try_from(required_data_shards)
4851        .map_err(|_| MetadataObjectKind::IndexRoot.too_large_error())?;
4852    options.index_root_fec_data_shards = required_data_shards;
4853    let required_parity_shards = compute_parity_u16(
4854        options.index_root_fec_data_shards as u64,
4855        options,
4856        "index_root_fec_parity_shards",
4857    )?;
4858    options.index_root_fec_parity_shards = options
4859        .index_root_fec_parity_shards
4860        .max(required_parity_shards);
4861    ensure_metadata_object_fits_class(index_root, options, MetadataObjectKind::IndexRoot)?;
4862    if let Some(dictionary) = dictionary {
4863        ensure_metadata_object_fits_class(dictionary, options, MetadataObjectKind::Dictionary)?;
4864    }
4865    Ok(MetadataClassPlan {
4866        options,
4867        index_root,
4868        dictionary,
4869    })
4870}
4871
4872fn plan_metadata_object_without_class(
4873    payload_len: usize,
4874    options: WriterOptions,
4875    kind: MetadataObjectKind,
4876) -> Result<PlannedEncryptedObject, FormatError> {
4877    let plan = plan_encrypted_object_without_class(payload_len, options)
4878        .map_err(|_| kind.too_large_error())?;
4879    if plan.data_block_count > u16::MAX as u32 || plan.parity_block_count > u16::MAX as u32 {
4880        return Err(kind.too_large_error());
4881    }
4882    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)
4883        .map_err(|_| kind.too_large_error())?;
4884    Ok(plan)
4885}
4886
4887fn ensure_metadata_object_fits_class(
4888    plan: PlannedEncryptedObject,
4889    options: WriterOptions,
4890    kind: MetadataObjectKind,
4891) -> Result<(), FormatError> {
4892    if plan.data_block_count > options.index_root_fec_data_shards as u32 {
4893        return Err(kind.too_large_error());
4894    }
4895    if plan.parity_block_count > options.index_root_fec_parity_shards as u32 {
4896        return Err(kind.too_large_error());
4897    }
4898    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)
4899        .map_err(|_| kind.too_large_error())
4900}
4901
4902fn payload_object_can_fit(payload_len: usize, options: WriterOptions) -> Result<bool, FormatError> {
4903    encrypted_object_can_fit(
4904        payload_len,
4905        options.fec_data_shards,
4906        options.fec_parity_shards,
4907        options,
4908    )
4909}
4910
4911fn index_object_can_fit(payload_len: usize, options: WriterOptions) -> Result<bool, FormatError> {
4912    encrypted_object_can_fit(
4913        payload_len,
4914        options.index_fec_data_shards,
4915        options.index_fec_parity_shards,
4916        options,
4917    )
4918}
4919
4920fn encrypted_object_can_fit(
4921    payload_len: usize,
4922    data_shard_max: u16,
4923    parity_shard_max: u16,
4924    options: WriterOptions,
4925) -> Result<bool, FormatError> {
4926    match plan_encrypted_object(payload_len, data_shard_max, parity_shard_max, options) {
4927        Ok(_) => Ok(true),
4928        Err(FormatError::WriterUnsupported("encrypted object exceeds u32 size limit"))
4929        | Err(FormatError::WriterUnsupported(
4930            "encrypted object exceeds its data shard class maximum",
4931        ))
4932        | Err(FormatError::WriterUnsupported(
4933            "encrypted object exceeds its parity shard class maximum",
4934        ))
4935        | Err(FormatError::WriterUnsupported(
4936            "encrypted object exceeds ReedSolomonGF16 shard limit",
4937        )) => Ok(false),
4938        Err(err) => Err(err),
4939    }
4940}
4941
4942fn plan_encrypted_object(
4943    payload_len: usize,
4944    data_shard_max: u16,
4945    parity_shard_max: u16,
4946    options: WriterOptions,
4947) -> Result<PlannedEncryptedObject, FormatError> {
4948    let plan = plan_encrypted_object_without_class(payload_len, options)?;
4949    if plan.data_block_count > data_shard_max as u32 {
4950        return Err(FormatError::WriterUnsupported(
4951            "encrypted object exceeds its data shard class maximum",
4952        ));
4953    }
4954    if plan.parity_block_count > parity_shard_max as u32 {
4955        return Err(FormatError::WriterUnsupported(
4956            "encrypted object exceeds its parity shard class maximum",
4957        ));
4958    }
4959    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)?;
4960    Ok(plan)
4961}
4962
4963fn plan_encrypted_object_without_class(
4964    payload_len: usize,
4965    options: WriterOptions,
4966) -> Result<PlannedEncryptedObject, FormatError> {
4967    let (data_block_count, encrypted_size) = encrypted_object_data_extent(payload_len, options)?;
4968    let parity_block_count = compute_parity(data_block_count as u64, options)?;
4969    Ok(PlannedEncryptedObject {
4970        data_block_count,
4971        parity_block_count,
4972        encrypted_size,
4973    })
4974}
4975
4976fn encrypted_object_data_extent(
4977    payload_len: usize,
4978    options: WriterOptions,
4979) -> Result<(u32, u32), FormatError> {
4980    let block_size = options.block_size as usize;
4981    let tag_len = options.aead_algo.tag_len();
4982    let total_before_padding =
4983        payload_len
4984            .checked_add(tag_len)
4985            .ok_or(FormatError::WriterUnsupported(
4986                "encrypted object size overflow",
4987            ))?;
4988    let remainder = total_before_padding % block_size;
4989    let encrypted_size = if remainder == 0 {
4990        total_before_padding
4991            .checked_add(block_size)
4992            .ok_or(FormatError::WriterUnsupported(
4993                "encrypted object size overflow",
4994            ))?
4995    } else {
4996        total_before_padding
4997            .checked_add(block_size - remainder)
4998            .ok_or(FormatError::WriterUnsupported(
4999                "encrypted object size overflow",
5000            ))?
5001    };
5002    let encrypted_size = u32_len(encrypted_size, "encrypted_size")
5003        .map_err(|_| FormatError::WriterUnsupported("encrypted object exceeds u32 size limit"))?;
5004    Ok((encrypted_size / options.block_size, encrypted_size))
5005}
5006
5007#[derive(Debug, Clone, Copy)]
5008struct ObjectEncryptionContext<'a> {
5009    key: &'a [u8; 32],
5010    nonce_seed: &'a [u8; 32],
5011    domain: &'a [u8],
5012    counter: u64,
5013    data_kind: BlockKind,
5014    parity_kind: BlockKind,
5015    data_shard_max: u16,
5016    class_parity_shard_max: u16,
5017    archive_uuid: &'a [u8; 16],
5018    session_id: &'a [u8; 16],
5019}
5020
5021fn encrypt_object(
5022    payload: &[u8],
5023    context: ObjectEncryptionContext<'_>,
5024    next_block_index: &mut u64,
5025    options: WriterOptions,
5026) -> Result<EncryptedObject, FormatError> {
5027    let block_size = options.block_size as usize;
5028    let encrypted = encrypt_object_payload(payload, context, options)?;
5029    if encrypted.len() % block_size != 0 {
5030        return Err(FormatError::WriterInvariant(
5031            "encrypted object is not block aligned",
5032        ));
5033    }
5034    let encrypted_size = u32_len(encrypted.len(), "encrypted_size")?;
5035    let data_shards = encrypted
5036        .chunks(block_size)
5037        .map(|chunk| chunk.to_vec())
5038        .collect::<Vec<_>>();
5039    let data_block_count = u32_len(data_shards.len(), "data_block_count")?;
5040    if data_block_count == 0 {
5041        return Err(FormatError::WriterInvariant(
5042            "encrypted object has no data blocks",
5043        ));
5044    }
5045    if data_block_count > context.data_shard_max as u32 {
5046        return Err(FormatError::WriterUnsupported(
5047            "encrypted object exceeds its data shard class maximum",
5048        ));
5049    }
5050    let required_parity = compute_object_parity(
5051        data_block_count as u64,
5052        options,
5053        context.class_parity_shard_max as u32,
5054    )?;
5055    if required_parity > context.class_parity_shard_max as u32 {
5056        return Err(FormatError::WriterUnsupported(
5057            "encrypted object exceeds its parity shard class maximum",
5058        ));
5059    }
5060    validate_object_shard_total(data_block_count, required_parity)?;
5061    let parity_count = required_parity as u16;
5062    let parity_shards = if parity_count == 0 {
5063        Vec::new()
5064    } else {
5065        encode_parity_gf16(&data_shards, parity_count as usize)?
5066    };
5067
5068    let first_block_index = *next_block_index;
5069    let mut records = Vec::with_capacity(data_shards.len() + parity_shards.len());
5070    for (index, payload) in data_shards.into_iter().enumerate() {
5071        records.push(BlockRecord {
5072            block_index: checked_u64_add(first_block_index, index as u64, "BlockRecord")?,
5073            kind: context.data_kind,
5074            flags: if index + 1 == data_block_count as usize {
5075                0x01
5076            } else {
5077                0
5078            },
5079            payload,
5080            record_crc32c: 0,
5081        });
5082    }
5083    let parity_first_block = checked_u64_add(first_block_index, data_block_count as u64, "FEC")?;
5084    for (index, payload) in parity_shards.into_iter().enumerate() {
5085        records.push(BlockRecord {
5086            block_index: checked_u64_add(parity_first_block, index as u64, "BlockRecord")?,
5087            kind: context.parity_kind,
5088            flags: 0,
5089            payload,
5090            record_crc32c: 0,
5091        });
5092    }
5093
5094    *next_block_index = checked_u64_add(
5095        first_block_index,
5096        data_block_count as u64 + parity_count as u64,
5097        "next_block_index",
5098    )?;
5099
5100    Ok(EncryptedObject {
5101        first_block_index,
5102        data_block_count,
5103        parity_block_count: parity_count as u32,
5104        encrypted_size,
5105        records,
5106    })
5107}
5108
5109fn serialize_zero_parity_encrypted_object(
5110    payload: &[u8],
5111    context: ObjectEncryptionContext<'_>,
5112    expected_extent: ObjectExtent,
5113    options: WriterOptions,
5114) -> Result<Vec<SerializedBlockRecord>, FormatError> {
5115    let planned = plan_encrypted_object(
5116        payload.len(),
5117        context.data_shard_max,
5118        context.class_parity_shard_max,
5119        options,
5120    )?;
5121    if planned.parity_block_count != 0 || expected_extent.parity_block_count != 0 {
5122        return Err(FormatError::WriterInvariant(
5123            "zero-parity serialization received a parity object",
5124        ));
5125    }
5126    if planned.data_block_count != expected_extent.data_block_count
5127        || planned.encrypted_size != expected_extent.encrypted_size
5128    {
5129        return Err(FormatError::WriterInvariant(
5130            "encrypted object did not match planned sizing",
5131        ));
5132    }
5133
5134    let block_size = options.block_size as usize;
5135    let encrypted = encrypt_object_payload(payload, context, options)?;
5136    if encrypted.len() != expected_extent.encrypted_size as usize
5137        || encrypted.len() % block_size != 0
5138    {
5139        return Err(FormatError::WriterInvariant(
5140            "encrypted object did not match planned sizing",
5141        ));
5142    }
5143    let data_block_count = encrypted.len() / block_size;
5144    if data_block_count == 0 || data_block_count != expected_extent.data_block_count as usize {
5145        return Err(FormatError::WriterInvariant(
5146            "encrypted object did not match planned sizing",
5147        ));
5148    }
5149
5150    let mut records = Vec::with_capacity(data_block_count);
5151    for (index, chunk) in encrypted.chunks(block_size).enumerate() {
5152        let block_index = checked_u64_add(
5153            expected_extent.first_block_index,
5154            index as u64,
5155            "BlockRecord",
5156        )?;
5157        let flags = if index + 1 == data_block_count {
5158            0x01
5159        } else {
5160            0
5161        };
5162        records.push(SerializedBlockRecord {
5163            block_index,
5164            bytes: BlockRecord::to_bytes_from_parts(block_index, context.data_kind, flags, chunk),
5165        });
5166    }
5167    Ok(records)
5168}
5169
5170fn encrypt_object_payload(
5171    payload: &[u8],
5172    context: ObjectEncryptionContext<'_>,
5173    options: WriterOptions,
5174) -> Result<Vec<u8>, FormatError> {
5175    let block_size = options.block_size as usize;
5176    let padded = suffix_pad_for_aead(payload, options.aead_algo.tag_len(), block_size)?;
5177    if matches!(options.aead_algo, AeadAlgo::None) {
5178        return Ok(padded);
5179    }
5180    let nonce = derive_nonce(
5181        context.nonce_seed,
5182        context.domain,
5183        context.archive_uuid,
5184        context.session_id,
5185        context.counter,
5186        options.aead_algo.nonce_len(),
5187    )?;
5188    let aad = build_aad(
5189        context.domain,
5190        context.archive_uuid,
5191        context.session_id,
5192        context.counter,
5193    )?;
5194    aead_encrypt(options.aead_algo, context.key, &nonce, &aad, &padded)
5195}
5196
5197fn validate_planned_object(
5198    object: &EncryptedObject,
5199    expected: PlannedEncryptedObject,
5200) -> Result<(), FormatError> {
5201    if object.data_block_count != expected.data_block_count
5202        || object.parity_block_count != expected.parity_block_count
5203        || object.encrypted_size != expected.encrypted_size
5204    {
5205        return Err(FormatError::WriterInvariant(
5206            "encrypted object did not match planned sizing",
5207        ));
5208    }
5209    Ok(())
5210}
5211
5212fn validate_planned_extent(
5213    object: &EncryptedObject,
5214    expected: ObjectExtent,
5215) -> Result<(), FormatError> {
5216    validate_planned_object(
5217        object,
5218        PlannedEncryptedObject {
5219            data_block_count: expected.data_block_count,
5220            parity_block_count: expected.parity_block_count,
5221            encrypted_size: expected.encrypted_size,
5222        },
5223    )?;
5224    if object.first_block_index != expected.first_block_index {
5225        return Err(FormatError::WriterInvariant(
5226            "encrypted object did not match planned extent",
5227        ));
5228    }
5229    Ok(())
5230}
5231
5232fn map_metadata_encrypt_error(error: FormatError, kind: MetadataObjectKind) -> FormatError {
5233    match error {
5234        FormatError::WriterUnsupported("encrypted object exceeds u32 size limit")
5235        | FormatError::WriterUnsupported("encrypted object exceeds its data shard class maximum")
5236        | FormatError::WriterUnsupported(
5237            "encrypted object exceeds its parity shard class maximum",
5238        )
5239        | FormatError::WriterUnsupported("encrypted object exceeds ReedSolomonGF16 shard limit") => {
5240            kind.too_large_error()
5241        }
5242        other => other,
5243    }
5244}
5245
5246#[derive(Debug, Clone, Copy)]
5247struct RootAuthFooterBuildInput<'a> {
5248    archive_uuid: [u8; 16],
5249    session_id: [u8; 16],
5250    options: WriterOptions,
5251    crypto_header: &'a [u8],
5252    volume_zero_manifest: &'a [u8; MANIFEST_FOOTER_LEN],
5253    index_root_plaintext: &'a [u8],
5254    index_root_extent: ObjectExtent,
5255    dictionary_extent: Option<(ObjectExtent, u32)>,
5256    shard_entries: &'a [ShardEntry],
5257    payload_objects: &'a [PayloadObject],
5258    directory_hint_entries: &'a [DirectoryHintShardEntry],
5259    data_leaf_hashes: &'a [(u64, [u8; 32])],
5260}
5261
5262fn build_root_auth_footer_from_leaf_hashes(
5263    config: RootAuthWriterConfig<'_>,
5264    authenticator: &mut RootAuthAuthenticator<'_>,
5265    input: RootAuthFooterBuildInput<'_>,
5266) -> Result<Vec<u8>, FormatError> {
5267    let mut sorted_leaf_hashes = input.data_leaf_hashes.to_vec();
5268    sorted_leaf_hashes.sort_by_key(|(block_index, _)| *block_index);
5269    let leaf_hashes = sorted_leaf_hashes
5270        .iter()
5271        .map(|(_, leaf_hash)| *leaf_hash)
5272        .collect::<Vec<_>>();
5273    let total_data_block_count = u64::try_from(leaf_hashes.len())
5274        .map_err(|_| FormatError::WriterUnsupported("root-auth data block count"))?;
5275    let data_block_merkle_root = data_block_merkle_root_from_leaf_hashes(&leaf_hashes);
5276
5277    let parsed_crypto = CryptoHeader::parse(
5278        input.crypto_header,
5279        u32_len(input.crypto_header.len(), "CryptoHeader")?,
5280    )?;
5281    let footer_length = root_auth_footer_wire_length(
5282        config.signer_identity.len(),
5283        config.authenticator_value_length as usize,
5284    )?;
5285    let root_auth_descriptor_digest = root_auth_descriptor_digest(
5286        config.authenticator_id,
5287        config.signer_identity_type,
5288        config.signer_identity,
5289        config.authenticator_value_length,
5290        footer_length,
5291    )?;
5292    let signer_identity_digest =
5293        signer_identity_digest(config.signer_identity_type, config.signer_identity)?;
5294    let manifest_pre_hmac = manifest_footer_global_pre_hmac_bytes(input.volume_zero_manifest);
5295    let critical_metadata_digest = critical_metadata_digest(CriticalMetadataDigestInputs {
5296        archive_uuid: input.archive_uuid,
5297        session_id: input.session_id,
5298        stripe_width: input.options.stripe_width,
5299        total_volumes: input.options.stripe_width,
5300        compression_algo: parsed_crypto.fixed.compression_algo,
5301        aead_algo: parsed_crypto.fixed.aead_algo,
5302        fec_algo: parsed_crypto.fixed.fec_algo,
5303        kdf_algo: parsed_crypto.fixed.kdf_algo,
5304        crypto_header_pre_hmac_bytes: parsed_crypto.hmac_covered_bytes,
5305        chunk_size: parsed_crypto.fixed.chunk_size,
5306        envelope_target_size: parsed_crypto.fixed.envelope_target_size,
5307        block_size: parsed_crypto.fixed.block_size,
5308        fec_data_shards: parsed_crypto.fixed.fec_data_shards,
5309        fec_parity_shards: parsed_crypto.fixed.fec_parity_shards,
5310        index_fec_data_shards: parsed_crypto.fixed.index_fec_data_shards,
5311        index_fec_parity_shards: parsed_crypto.fixed.index_fec_parity_shards,
5312        index_root_fec_data_shards: parsed_crypto.fixed.index_root_fec_data_shards,
5313        index_root_fec_parity_shards: parsed_crypto.fixed.index_root_fec_parity_shards,
5314        volume_loss_tolerance: parsed_crypto.fixed.volume_loss_tolerance,
5315        bit_rot_buffer_pct: parsed_crypto.fixed.bit_rot_buffer_pct,
5316        has_dictionary: parsed_crypto.fixed.has_dictionary,
5317        manifest_footer_global_pre_hmac_bytes: &manifest_pre_hmac,
5318        index_root_first_block: input.index_root_extent.first_block_index,
5319        index_root_data_block_count: input.index_root_extent.data_block_count,
5320        index_root_parity_block_count: input.index_root_extent.parity_block_count,
5321        index_root_encrypted_size: input.index_root_extent.encrypted_size,
5322        index_root_decompressed_size: u32_len(input.index_root_plaintext.len(), "IndexRoot")?,
5323        root_auth_descriptor_digest,
5324    })?;
5325    let index_digest = index_digest(input.index_root_plaintext);
5326    let fec_layout_rows = writer_fec_layout_rows_from_extents(
5327        input.index_root_extent,
5328        u32_len(input.index_root_plaintext.len(), "IndexRoot")?,
5329        input.dictionary_extent,
5330        input.shard_entries,
5331        input.payload_objects,
5332        input.directory_hint_entries,
5333    );
5334    let expected_data_block_count = fec_layout_rows.iter().try_fold(0u64, |total, row| {
5335        if row.present {
5336            checked_u64_add(
5337                total,
5338                row.data_block_count as u64,
5339                "root-auth data block count",
5340            )
5341        } else {
5342            Ok(total)
5343        }
5344    })?;
5345    if expected_data_block_count != total_data_block_count {
5346        return Err(FormatError::WriterInvariant(
5347            "root-auth data block count does not match FEC layout",
5348        ));
5349    }
5350    let fec_layout_digest = fec_layout_digest(&fec_layout_rows)?;
5351    let archive_root = archive_root(ArchiveRootInputs {
5352        archive_uuid: input.archive_uuid,
5353        session_id: input.session_id,
5354        format_version: FORMAT_VERSION,
5355        volume_format_rev: VOLUME_FORMAT_REV,
5356        compression_algo: parsed_crypto.fixed.compression_algo,
5357        aead_algo: parsed_crypto.fixed.aead_algo,
5358        fec_algo: parsed_crypto.fixed.fec_algo,
5359        kdf_algo: parsed_crypto.fixed.kdf_algo,
5360        critical_metadata_digest,
5361        index_digest,
5362        fec_layout_digest,
5363        total_data_block_count,
5364        data_block_merkle_root,
5365        root_auth_descriptor_digest,
5366        signer_identity_digest,
5367    });
5368    let authenticator_value = authenticator(&RootAuthSigningRequest {
5369        archive_uuid: input.archive_uuid,
5370        session_id: input.session_id,
5371        archive_root,
5372    })?;
5373    if authenticator_value.len() != config.authenticator_value_length as usize {
5374        return Err(FormatError::WriterUnsupported(
5375            "root-auth authenticator length mismatch",
5376        ));
5377    }
5378
5379    RootAuthFooterV1 {
5380        archive_uuid: input.archive_uuid,
5381        session_id: input.session_id,
5382        authenticator_id: config.authenticator_id,
5383        signer_identity_type: config.signer_identity_type,
5384        signer_identity_bytes: config.signer_identity.to_vec(),
5385        authenticator_value,
5386        total_data_block_count,
5387        critical_metadata_digest,
5388        index_digest,
5389        fec_layout_digest,
5390        data_block_merkle_root,
5391        signer_identity_digest,
5392        archive_root,
5393        footer_crc32c: 0,
5394    }
5395    .to_bytes()
5396}
5397
5398fn writer_fec_layout_rows_from_extents(
5399    index_root_extent: ObjectExtent,
5400    index_root_plain_size: u32,
5401    dictionary_extent: Option<(ObjectExtent, u32)>,
5402    shard_entries: &[ShardEntry],
5403    payload_objects: &[PayloadObject],
5404    directory_hint_entries: &[DirectoryHintShardEntry],
5405) -> Vec<FecLayoutObjectRow> {
5406    let mut rows = Vec::new();
5407    rows.push(FecLayoutObjectRow {
5408        object_class: 1,
5409        present: true,
5410        object_id: 0,
5411        first_block_index: index_root_extent.first_block_index,
5412        data_block_count: index_root_extent.data_block_count,
5413        parity_block_count: index_root_extent.parity_block_count,
5414        encrypted_size: index_root_extent.encrypted_size,
5415        plain_size: index_root_plain_size,
5416    });
5417    if let Some((dictionary, decompressed_size)) = dictionary_extent {
5418        rows.push(FecLayoutObjectRow {
5419            object_class: 2,
5420            present: true,
5421            object_id: 0,
5422            first_block_index: dictionary.first_block_index,
5423            data_block_count: dictionary.data_block_count,
5424            parity_block_count: dictionary.parity_block_count,
5425            encrypted_size: dictionary.encrypted_size,
5426            plain_size: decompressed_size,
5427        });
5428    } else {
5429        rows.push(FecLayoutObjectRow {
5430            object_class: 2,
5431            present: false,
5432            object_id: 0,
5433            first_block_index: 0,
5434            data_block_count: 0,
5435            parity_block_count: 0,
5436            encrypted_size: 0,
5437            plain_size: 0,
5438        });
5439    }
5440    for entry in shard_entries {
5441        rows.push(FecLayoutObjectRow {
5442            object_class: 3,
5443            present: true,
5444            object_id: entry.shard_index,
5445            first_block_index: entry.first_block_index,
5446            data_block_count: entry.data_block_count,
5447            parity_block_count: entry.parity_block_count,
5448            encrypted_size: entry.encrypted_size,
5449            plain_size: entry.decompressed_size,
5450        });
5451    }
5452    for payload in payload_objects {
5453        rows.push(FecLayoutObjectRow {
5454            object_class: 4,
5455            present: true,
5456            object_id: payload.envelope_index,
5457            first_block_index: payload.object.first_block_index,
5458            data_block_count: payload.object.data_block_count,
5459            parity_block_count: payload.object.parity_block_count,
5460            encrypted_size: payload.object.encrypted_size,
5461            plain_size: payload.plaintext_size,
5462        });
5463    }
5464    for entry in directory_hint_entries {
5465        rows.push(FecLayoutObjectRow {
5466            object_class: 5,
5467            present: true,
5468            object_id: entry.hint_shard_index,
5469            first_block_index: entry.first_block_index,
5470            data_block_count: entry.data_block_count,
5471            parity_block_count: entry.parity_block_count,
5472            encrypted_size: entry.encrypted_size,
5473            plain_size: entry.decompressed_size,
5474        });
5475    }
5476    rows
5477}
5478
5479fn manifest_footer_global_pre_hmac_bytes(manifest_footer: &[u8; MANIFEST_FOOTER_LEN]) -> [u8; 104] {
5480    let mut bytes = [0u8; 104];
5481    bytes.copy_from_slice(&manifest_footer[..104]);
5482    bytes[36..40].fill(0);
5483    bytes
5484}
5485
5486fn root_auth_footer_wire_length(
5487    signer_identity_len: usize,
5488    authenticator_value_len: usize,
5489) -> Result<u32, FormatError> {
5490    validate_root_auth_variable_lengths_for_writer(signer_identity_len, authenticator_value_len)?;
5491    let len = crate::format::ROOT_AUTH_FOOTER_FIXED_LEN
5492        .checked_add(signer_identity_len)
5493        .and_then(|value| value.checked_add(authenticator_value_len))
5494        .and_then(|value| value.checked_add(4))
5495        .ok_or(FormatError::WriterUnsupported(
5496            "RootAuthFooterV1 length overflow",
5497        ))?;
5498    if len > READER_MAX_ROOT_AUTH_FOOTER_LEN as usize {
5499        return Err(FormatError::ReaderResourceLimitExceeded {
5500            field: "RootAuthFooterV1 length",
5501            cap: READER_MAX_ROOT_AUTH_FOOTER_LEN as u64,
5502            actual: len as u64,
5503        });
5504    }
5505    u32::try_from(len).map_err(|_| FormatError::WriterUnsupported("RootAuthFooterV1 length"))
5506}
5507
5508fn validate_root_auth_writer_config(config: RootAuthWriterConfig<'_>) -> Result<(), FormatError> {
5509    root_auth_footer_wire_length(
5510        config.signer_identity.len(),
5511        config.authenticator_value_length as usize,
5512    )?;
5513    Ok(())
5514}
5515
5516fn validate_root_auth_variable_lengths_for_writer(
5517    signer_identity_len: usize,
5518    authenticator_value_len: usize,
5519) -> Result<(), FormatError> {
5520    if signer_identity_len > READER_MAX_ROOT_AUTH_SIGNER_IDENTITY_LEN as usize {
5521        return Err(FormatError::ReaderResourceLimitExceeded {
5522            field: "RootAuthFooterV1 signer identity length",
5523            cap: READER_MAX_ROOT_AUTH_SIGNER_IDENTITY_LEN as u64,
5524            actual: signer_identity_len as u64,
5525        });
5526    }
5527    if authenticator_value_len > READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as usize {
5528        return Err(FormatError::ReaderResourceLimitExceeded {
5529            field: "RootAuthFooterV1 authenticator value length",
5530            cap: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as u64,
5531            actual: authenticator_value_len as u64,
5532        });
5533    }
5534    Ok(())
5535}
5536
5537#[allow(clippy::too_many_arguments)]
5538fn build_manifest_footer(
5539    subkeys: &Subkeys,
5540    aead_algo: AeadAlgo,
5541    archive_uuid: [u8; 16],
5542    session_id: [u8; 16],
5543    volume_index: u32,
5544    total_volumes: u32,
5545    index_root_extent: &ObjectExtent,
5546    index_root_decompressed_size: usize,
5547) -> Result<[u8; MANIFEST_FOOTER_LEN], FormatError> {
5548    let mut footer = ManifestFooter {
5549        archive_uuid,
5550        session_id,
5551        volume_index,
5552        is_authoritative: 1,
5553        total_volumes,
5554        index_root_first_block: index_root_extent.first_block_index,
5555        index_root_data_block_count: index_root_extent.data_block_count,
5556        index_root_parity_block_count: index_root_extent.parity_block_count,
5557        index_root_encrypted_size: index_root_extent.encrypted_size,
5558        index_root_decompressed_size: u32_len(index_root_decompressed_size, "IndexRoot")?,
5559        manifest_hmac: [0u8; 32],
5560    };
5561    let mut bytes = footer.to_bytes();
5562    footer.manifest_hmac = compute_integrity_tag(
5563        HmacDomain::ManifestFooter,
5564        aead_algo,
5565        Some(&subkeys.mac_key),
5566        &archive_uuid,
5567        &session_id,
5568        &bytes[..104],
5569    )?;
5570    bytes = footer.to_bytes();
5571    Ok(bytes)
5572}
5573
5574#[derive(Debug, Clone, Copy)]
5575struct VolumeTrailerBuildInput<'a> {
5576    subkeys: &'a Subkeys,
5577    aead_algo: AeadAlgo,
5578    archive_uuid: [u8; 16],
5579    session_id: [u8; 16],
5580    volume_index: u32,
5581    block_count: u64,
5582    bytes_written: u64,
5583    manifest_footer_offset: u64,
5584    closed_at_ns: i64,
5585    root_auth_footer: Option<(u64, u32)>,
5586}
5587
5588fn build_volume_trailer(
5589    input: VolumeTrailerBuildInput<'_>,
5590) -> Result<[u8; VOLUME_TRAILER_LEN], FormatError> {
5591    let (root_auth_footer_offset, root_auth_footer_length, root_auth_flags) =
5592        match input.root_auth_footer {
5593            Some((offset, length)) => (offset, length, 0x0000_0001),
5594            None => (0, 0, 0),
5595        };
5596    let mut trailer = VolumeTrailer {
5597        archive_uuid: input.archive_uuid,
5598        session_id: input.session_id,
5599        volume_index: input.volume_index,
5600        block_count: input.block_count,
5601        bytes_written: input.bytes_written,
5602        manifest_footer_offset: input.manifest_footer_offset,
5603        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
5604        closed_at_ns: input.closed_at_ns,
5605        root_auth_footer_offset,
5606        root_auth_footer_length,
5607        root_auth_flags,
5608        trailer_hmac: [0u8; 32],
5609    };
5610    let mut bytes = trailer.to_bytes();
5611    trailer.trailer_hmac = compute_integrity_tag(
5612        HmacDomain::VolumeTrailer,
5613        input.aead_algo,
5614        Some(&input.subkeys.mac_key),
5615        &input.archive_uuid,
5616        &input.session_id,
5617        &bytes[..96],
5618    )?;
5619    bytes = trailer.to_bytes();
5620    Ok(bytes)
5621}
5622
5623struct BuiltCmra {
5624    bytes: Vec<u8>,
5625    shard_size: u32,
5626    data_shard_count: u16,
5627    parity_shard_count: u16,
5628    image_length: u32,
5629    image_sha256: [u8; 32],
5630}
5631
5632#[derive(Debug, Clone, Copy)]
5633struct CmraBuildInput<'a> {
5634    volume_header_bytes: &'a [u8; VOLUME_HEADER_LEN],
5635    crypto_header: &'a [u8],
5636    block_count: u64,
5637    manifest_footer_offset: u64,
5638    manifest_footer: &'a [u8; MANIFEST_FOOTER_LEN],
5639    root_auth_footer_offset: Option<u64>,
5640    root_auth_footer: Option<&'a [u8]>,
5641    trailer_offset: u64,
5642    trailer: &'a [u8; VOLUME_TRAILER_LEN],
5643    cmra_offset: u64,
5644    options: WriterOptions,
5645    archive_uuid: [u8; 16],
5646    session_id: [u8; 16],
5647    volume_index: u32,
5648}
5649
5650fn build_v41_cmra(input: CmraBuildInput<'_>) -> Result<BuiltCmra, FormatError> {
5651    let block_record_len = input.options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
5652    let block_records_offset = VOLUME_HEADER_LEN as u64 + input.crypto_header.len() as u64;
5653    let block_records_length = checked_u64_mul(
5654        input.block_count,
5655        block_record_len,
5656        "CMRA BlockRecord length overflow",
5657    )?;
5658    let manifest_end = input
5659        .manifest_footer_offset
5660        .checked_add(MANIFEST_FOOTER_LEN as u64)
5661        .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?;
5662    let root_auth_footer_length = input
5663        .root_auth_footer
5664        .map(|footer| u32_len(footer.len(), "RootAuthFooterV1"))
5665        .transpose()?;
5666    match (input.root_auth_footer_offset, root_auth_footer_length) {
5667        (Some(offset), Some(length)) => {
5668            if manifest_end != offset
5669                || offset
5670                    .checked_add(length as u64)
5671                    .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?
5672                    != input.trailer_offset
5673            {
5674                return Err(FormatError::WriterInvariant(
5675                    "RootAuthFooter does not sit between ManifestFooter and VolumeTrailer",
5676                ));
5677            }
5678        }
5679        (None, None) => {
5680            if manifest_end != input.trailer_offset {
5681                return Err(FormatError::WriterInvariant(
5682                    "ManifestFooter does not end at VolumeTrailer",
5683                ));
5684            }
5685        }
5686        _ => {
5687            return Err(FormatError::WriterInvariant(
5688                "RootAuthFooter offset/bytes mismatch",
5689            ));
5690        }
5691    }
5692    let body_bytes_before_cmra = input
5693        .trailer_offset
5694        .checked_add(VOLUME_TRAILER_LEN as u64)
5695        .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?;
5696    if body_bytes_before_cmra != input.cmra_offset {
5697        return Err(FormatError::WriterInvariant(
5698            "CMRA does not start after VolumeTrailer",
5699        ));
5700    }
5701
5702    let mut regions = vec![
5703        SerializedRegion {
5704            region_type: 1,
5705            offset: 0,
5706            bytes: input.volume_header_bytes.to_vec(),
5707        },
5708        SerializedRegion {
5709            region_type: 2,
5710            offset: VOLUME_HEADER_LEN as u64,
5711            bytes: input.crypto_header.to_vec(),
5712        },
5713        SerializedRegion {
5714            region_type: 3,
5715            offset: input.manifest_footer_offset,
5716            bytes: input.manifest_footer.to_vec(),
5717        },
5718    ];
5719    if let (Some(offset), Some(footer)) = (input.root_auth_footer_offset, input.root_auth_footer) {
5720        regions.push(SerializedRegion {
5721            region_type: 4,
5722            offset,
5723            bytes: footer.to_vec(),
5724        });
5725    }
5726    regions.push(SerializedRegion {
5727        region_type: 5,
5728        offset: input.trailer_offset,
5729        bytes: input.trailer.to_vec(),
5730    });
5731    let image = CriticalMetadataImage {
5732        archive_uuid: input.archive_uuid,
5733        session_id: input.session_id,
5734        volume_index: input.volume_index,
5735        stripe_width: input.options.stripe_width,
5736        layout_flags: if input.root_auth_footer.is_some() {
5737            0x0000_0001
5738        } else {
5739            0
5740        },
5741        volume_header_offset: 0,
5742        volume_header_length: VOLUME_HEADER_LEN as u32,
5743        crypto_header_offset: VOLUME_HEADER_LEN as u64,
5744        crypto_header_length: u32_len(input.crypto_header.len(), "CryptoHeader")?,
5745        block_records_offset,
5746        block_records_length,
5747        block_count: input.block_count,
5748        manifest_footer_offset: input.manifest_footer_offset,
5749        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
5750        root_auth_footer_offset: input.root_auth_footer_offset.unwrap_or(0),
5751        root_auth_footer_length: root_auth_footer_length.unwrap_or(0),
5752        volume_trailer_offset: input.trailer_offset,
5753        volume_trailer_length: VOLUME_TRAILER_LEN as u32,
5754        body_bytes_before_cmra,
5755        volume_header_sha256: sha256_bytes(input.volume_header_bytes),
5756        crypto_header_sha256: sha256_bytes(input.crypto_header),
5757        manifest_footer_sha256: sha256_bytes(input.manifest_footer),
5758        root_auth_footer_sha256: input
5759            .root_auth_footer
5760            .map(sha256_bytes)
5761            .unwrap_or([0u8; 32]),
5762        volume_trailer_sha256: sha256_bytes(input.trailer),
5763        regions,
5764    };
5765    let image_bytes = image.to_bytes()?;
5766    let image_sha256 = sha256_bytes(&image_bytes);
5767    let data_shard_count = ceil_div(image_bytes.len() as u64, CMRA_SHARD_SIZE as u64)?;
5768    let data_shard_count_u16 = u16::try_from(data_shard_count)
5769        .map_err(|_| FormatError::WriterUnsupported("CMRA data shard count"))?;
5770    let parity_lower = cmra_min_parity_shards(data_shard_count, input.options.bit_rot_buffer_pct)?;
5771    let parity_upper = cmra_min_parity_shards(data_shard_count, READER_MAX_CMRA_PARITY_PCT as u8)?;
5772    if parity_lower > parity_upper {
5773        return Err(FormatError::WriterUnsupported("CMRA parity bounds"));
5774    }
5775    let parity_shard_count_u16 = u16::try_from(parity_lower)
5776        .map_err(|_| FormatError::WriterUnsupported("CMRA parity shard count"))?;
5777
5778    let mut data_shards = Vec::with_capacity(data_shard_count as usize);
5779    for idx in 0..data_shard_count as usize {
5780        let start = idx * CMRA_SHARD_SIZE;
5781        let end = (start + CMRA_SHARD_SIZE).min(image_bytes.len());
5782        let mut shard = vec![0u8; CMRA_SHARD_SIZE];
5783        if start < image_bytes.len() {
5784            shard[..end - start].copy_from_slice(&image_bytes[start..end]);
5785        }
5786        data_shards.push(shard);
5787    }
5788    let parity_shards = encode_parity_gf16(&data_shards, parity_shard_count_u16 as usize)?;
5789
5790    let header = CriticalMetadataRecoveryHeader {
5791        shard_size: CMRA_SHARD_SIZE as u32,
5792        data_shard_count: data_shard_count_u16,
5793        parity_shard_count: parity_shard_count_u16,
5794        image_length: u32_len(image_bytes.len(), "CriticalMetadataImageV1")?,
5795        archive_uuid_hint: input.archive_uuid,
5796        session_id_hint: input.session_id,
5797        volume_index_hint: input.volume_index,
5798        image_sha256,
5799        header_crc32c: 0,
5800    };
5801    let mut cmra = Vec::new();
5802    cmra.extend_from_slice(&header.to_bytes());
5803    for (idx, payload) in data_shards.into_iter().enumerate() {
5804        let payload_len = if idx + 1 == data_shard_count as usize {
5805            let final_len = image_bytes.len() - idx * CMRA_SHARD_SIZE;
5806            if final_len == 0 {
5807                CMRA_SHARD_SIZE
5808            } else {
5809                final_len
5810            }
5811        } else {
5812            CMRA_SHARD_SIZE
5813        };
5814        cmra.extend_from_slice(
5815            &CriticalMetadataRecoveryShard {
5816                shard_index: u16::try_from(idx)
5817                    .map_err(|_| FormatError::WriterUnsupported("CMRA shard index"))?,
5818                shard_role: 0,
5819                shard_payload_length: u32_len(payload_len, "CMRA shard payload")?,
5820                payload,
5821                shard_crc32c: 0,
5822            }
5823            .to_bytes(CMRA_SHARD_SIZE)?,
5824        );
5825    }
5826    for (idx, payload) in parity_shards.into_iter().enumerate() {
5827        let shard_index = data_shard_count
5828            .checked_add(idx as u64)
5829            .ok_or(FormatError::WriterUnsupported("CMRA shard index overflow"))?;
5830        cmra.extend_from_slice(
5831            &CriticalMetadataRecoveryShard {
5832                shard_index: u16::try_from(shard_index)
5833                    .map_err(|_| FormatError::WriterUnsupported("CMRA shard index"))?,
5834                shard_role: 1,
5835                shard_payload_length: CMRA_SHARD_SIZE as u32,
5836                payload,
5837                shard_crc32c: 0,
5838            }
5839            .to_bytes(CMRA_SHARD_SIZE)?,
5840        );
5841    }
5842
5843    Ok(BuiltCmra {
5844        bytes: cmra,
5845        shard_size: CMRA_SHARD_SIZE as u32,
5846        data_shard_count: data_shard_count_u16,
5847        parity_shard_count: parity_shard_count_u16,
5848        image_length: u32_len(image_bytes.len(), "CriticalMetadataImageV1")?,
5849        image_sha256,
5850    })
5851}
5852
5853fn cmra_min_parity_shards(data_shard_count: u64, pct: u8) -> Result<u64, FormatError> {
5854    let by_pct = ceil_div(
5855        checked_u64_mul(data_shard_count, pct as u64, "CMRA parity overflow")?,
5856        100,
5857    )?;
5858    Ok(2u64.max(by_pct))
5859}
5860
5861fn compute_object_parity(
5862    data_block_count: u64,
5863    options: WriterOptions,
5864    class_parity_shard_max: u32,
5865) -> Result<u32, FormatError> {
5866    let computed = compute_parity(data_block_count, options)?;
5867    if computed > class_parity_shard_max {
5868        return Err(FormatError::WriterUnsupported(
5869            "encrypted object exceeds its parity shard class maximum",
5870        ));
5871    }
5872    Ok(computed)
5873}
5874
5875fn validate_object_shard_total(
5876    data_block_count: u32,
5877    parity_block_count: u32,
5878) -> Result<(), FormatError> {
5879    let total = checked_u64_add(
5880        data_block_count as u64,
5881        parity_block_count as u64,
5882        "encrypted object shard total overflow",
5883    )?;
5884    if total > MAX_REED_SOLOMON_GF16_SHARDS {
5885        return Err(FormatError::WriterUnsupported(
5886            "encrypted object exceeds ReedSolomonGF16 shard limit",
5887        ));
5888    }
5889    Ok(())
5890}
5891
5892fn compute_parity_u16(
5893    data_block_count: u64,
5894    options: WriterOptions,
5895    field: &'static str,
5896) -> Result<u16, FormatError> {
5897    let parity = compute_parity(data_block_count, options)?;
5898    u16::try_from(parity).map_err(|_| FormatError::WriterUnsupported(field))
5899}
5900
5901fn compute_parity(data_block_count: u64, options: WriterOptions) -> Result<u32, FormatError> {
5902    let min_parity = if options.volume_loss_tolerance > 0 || options.bit_rot_buffer_pct > 0 {
5903        1u64
5904    } else {
5905        0u64
5906    };
5907    let mut parity = 0u64;
5908    for _ in 0..100 {
5909        let total = data_block_count
5910            .checked_add(parity)
5911            .ok_or(FormatError::WriterUnsupported("parity total overflow"))?;
5912        let by_volume = checked_u64_mul(
5913            options.volume_loss_tolerance as u64,
5914            ceil_div(total, options.stripe_width as u64)?,
5915            "volume-loss parity overflow",
5916        )?;
5917        let by_bitrot = ceil_div(
5918            checked_u64_mul(
5919                total,
5920                options.bit_rot_buffer_pct as u64,
5921                "bit-rot parity overflow",
5922            )?,
5923            100,
5924        )?;
5925        let next = by_volume
5926            .checked_add(by_bitrot)
5927            .ok_or(FormatError::WriterUnsupported("parity overflow"))?
5928            .max(min_parity);
5929        if next == parity {
5930            return u32::try_from(next).map_err(|_| FormatError::WriterUnsupported("parity count"));
5931        }
5932        parity = next;
5933    }
5934    Err(FormatError::WriterUnsupported(
5935        "parity calculation did not converge",
5936    ))
5937}
5938
5939fn ceil_div(numerator: u64, denominator: u64) -> Result<u64, FormatError> {
5940    if denominator == 0 {
5941        return Err(FormatError::WriterUnsupported("division by zero"));
5942    }
5943    numerator
5944        .checked_add(denominator - 1)
5945        .ok_or(FormatError::WriterUnsupported("ceiling division overflow"))
5946        .map(|value| value / denominator)
5947}
5948
5949fn checked_u64_mul(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
5950    lhs.checked_mul(rhs)
5951        .ok_or(FormatError::WriterUnsupported(field))
5952}
5953
5954fn build_bootstrap_sidecar(
5955    subkeys: &Subkeys,
5956    aead_algo: AeadAlgo,
5957    archive_uuid: [u8; 16],
5958    session_id: [u8; 16],
5959    manifest_footer: &[u8; MANIFEST_FOOTER_LEN],
5960    index_root_records: &[BlockRecord],
5961    dictionary_records: Option<&[BlockRecord]>,
5962) -> Result<Vec<u8>, FormatError> {
5963    let index_records_len = index_root_records.iter().try_fold(0usize, |sum, record| {
5964        checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
5965    })?;
5966    let dictionary_records_len = dictionary_records
5967        .unwrap_or(&[])
5968        .iter()
5969        .try_fold(0usize, |sum, record| {
5970            checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
5971        })?;
5972    let manifest_offset = BOOTSTRAP_SIDECAR_HEADER_LEN as u64;
5973    let index_root_offset = manifest_offset + MANIFEST_FOOTER_LEN as u64;
5974    let dictionary_offset = if dictionary_records.is_some() {
5975        index_root_offset + index_records_len as u64
5976    } else {
5977        0
5978    };
5979    let mut header = BootstrapSidecarHeader {
5980        archive_uuid,
5981        session_id,
5982        flags: if dictionary_records.is_some() {
5983            0x07
5984        } else {
5985            0x03
5986        },
5987        manifest_footer_offset: manifest_offset,
5988        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
5989        index_root_records_offset: index_root_offset,
5990        index_root_records_length: index_records_len as u64,
5991        dictionary_records_offset: dictionary_offset,
5992        dictionary_records_length: dictionary_records_len as u64,
5993        sidecar_hmac: [0u8; 32],
5994        header_crc32c: 0,
5995    };
5996    let mut header_bytes = header.to_bytes();
5997    header.sidecar_hmac = compute_integrity_tag(
5998        HmacDomain::BootstrapSidecar,
5999        aead_algo,
6000        Some(&subkeys.mac_key),
6001        &archive_uuid,
6002        &session_id,
6003        &header_bytes[..92],
6004    )?;
6005    header_bytes = header.to_bytes();
6006
6007    let mut sidecar = Vec::with_capacity(
6008        BOOTSTRAP_SIDECAR_HEADER_LEN
6009            + MANIFEST_FOOTER_LEN
6010            + index_records_len
6011            + dictionary_records_len,
6012    );
6013    sidecar.extend_from_slice(&header_bytes);
6014    sidecar.extend_from_slice(manifest_footer);
6015    for record in index_root_records {
6016        sidecar.extend_from_slice(&record.to_bytes());
6017    }
6018    if let Some(dictionary_records) = dictionary_records {
6019        for record in dictionary_records {
6020            sidecar.extend_from_slice(&record.to_bytes());
6021        }
6022    }
6023    Ok(sidecar)
6024}
6025
6026struct StreamingMemberReader<'a> {
6027    prefix: Cursor<Vec<u8>>,
6028    file: Box<dyn Read + 'a>,
6029    remaining_file_bytes: u64,
6030    remaining_padding_bytes: usize,
6031    pushback: Vec<u8>,
6032}
6033
6034impl<'a> StreamingMemberReader<'a> {
6035    fn new(file: Box<dyn Read + 'a>, prefix: Vec<u8>, file_size: u64) -> Self {
6036        Self {
6037            prefix: Cursor::new(prefix),
6038            file,
6039            remaining_file_bytes: file_size,
6040            remaining_padding_bytes: padding_to_512_u64(file_size) as usize,
6041            pushback: Vec::new(),
6042        }
6043    }
6044
6045    fn push_back(&mut self, bytes: Vec<u8>) {
6046        if bytes.is_empty() {
6047            return;
6048        }
6049        if self.pushback.is_empty() {
6050            self.pushback = bytes;
6051        } else {
6052            let mut merged = bytes;
6053            merged.extend_from_slice(&self.pushback);
6054            self.pushback = merged;
6055        }
6056    }
6057}
6058
6059impl Read for StreamingMemberReader<'_> {
6060    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
6061        if out.is_empty() {
6062            return Ok(0);
6063        }
6064
6065        let mut written = 0usize;
6066        if !self.pushback.is_empty() {
6067            let count = out.len().min(self.pushback.len());
6068            out[..count].copy_from_slice(&self.pushback[..count]);
6069            self.pushback.drain(..count);
6070            written += count;
6071            if written == out.len() {
6072                return Ok(written);
6073            }
6074        }
6075
6076        let prefix_count = self.prefix.read(&mut out[written..])?;
6077        written += prefix_count;
6078        if written == out.len() {
6079            return Ok(written);
6080        }
6081
6082        if self.remaining_file_bytes > 0 {
6083            let max_file_read = (out.len() - written)
6084                .min(usize::try_from(self.remaining_file_bytes).unwrap_or(usize::MAX));
6085            let count = self.file.read(&mut out[written..written + max_file_read])?;
6086            if count == 0 {
6087                return Err(std::io::Error::new(
6088                    std::io::ErrorKind::UnexpectedEof,
6089                    "file source ended before declared size",
6090                ));
6091            }
6092            self.remaining_file_bytes -= count as u64;
6093            written += count;
6094            if written == out.len() {
6095                return Ok(written);
6096            }
6097            if self.remaining_file_bytes > 0 {
6098                return Ok(written);
6099            }
6100        }
6101
6102        if self.remaining_padding_bytes > 0 {
6103            let count = (out.len() - written).min(self.remaining_padding_bytes);
6104            out[written..written + count].fill(0);
6105            self.remaining_padding_bytes -= count;
6106            written += count;
6107        }
6108
6109        Ok(written)
6110    }
6111}
6112
6113fn build_regular_file_member_prefix(
6114    path: &[u8],
6115    file_size: u64,
6116    mode: u32,
6117    mtime: u64,
6118) -> Result<Vec<u8>, FormatError> {
6119    let mut out = Vec::new();
6120    let header_path = if path_requires_pax(path) {
6121        let pax_payload = build_pax_record("path", path)?;
6122        let pax_header = build_ustar_header(
6123            b"PaxHeaders/path",
6124            pax_payload.len() as u64,
6125            0o644,
6126            mtime,
6127            b'x',
6128        )?;
6129        out.extend_from_slice(&pax_header);
6130        out.extend_from_slice(&pax_payload);
6131        out.resize(out.len() + padding_to_512(pax_payload.len()), 0);
6132        pax_ustar_fallback_path(path)
6133    } else {
6134        path.to_vec()
6135    };
6136
6137    let header = build_ustar_header(&header_path, file_size, mode, mtime, b'0')?;
6138    out.extend_from_slice(&header);
6139    Ok(out)
6140}
6141
6142#[cfg(test)]
6143fn build_regular_file_member_group(
6144    path: &[u8],
6145    contents: &[u8],
6146    mode: u32,
6147    mtime: u64,
6148) -> Result<Vec<u8>, FormatError> {
6149    let mut out = build_regular_file_member_prefix(path, contents.len() as u64, mode, mtime)?;
6150    out.extend_from_slice(contents);
6151    out.resize(out.len() + padding_to_512(contents.len()), 0);
6152    Ok(out)
6153}
6154
6155fn path_requires_pax(path: &[u8]) -> bool {
6156    path.len() > 100 || !path.is_ascii()
6157}
6158
6159fn pax_ustar_fallback_path(path: &[u8]) -> Vec<u8> {
6160    path.rsplit(|byte| *byte == b'/')
6161        .next()
6162        .filter(|component| !component.is_empty() && component.len() <= 100 && component.is_ascii())
6163        .map(|component| component.to_vec())
6164        .unwrap_or_else(|| b"pax-file".to_vec())
6165}
6166
6167fn build_pax_record(key: &str, value: &[u8]) -> Result<Vec<u8>, FormatError> {
6168    let body_len = checked_usize_add(key.len(), 1, "PAX record")?;
6169    let body_len = checked_usize_add(body_len, value.len(), "PAX record")?;
6170    let body_len = checked_usize_add(body_len, 1, "PAX record")?;
6171    let mut digits = 1usize;
6172    loop {
6173        let len = checked_usize_add(digits, 1, "PAX record")?;
6174        let len = checked_usize_add(len, body_len, "PAX record")?;
6175        let next_digits = len.to_string().len();
6176        if next_digits == digits {
6177            let mut out = Vec::with_capacity(len);
6178            out.extend_from_slice(len.to_string().as_bytes());
6179            out.push(b' ');
6180            out.extend_from_slice(key.as_bytes());
6181            out.push(b'=');
6182            out.extend_from_slice(value);
6183            out.push(b'\n');
6184            return Ok(out);
6185        }
6186        digits = next_digits;
6187    }
6188}
6189
6190fn build_ustar_header(
6191    path: &[u8],
6192    size: u64,
6193    mode: u32,
6194    mtime: u64,
6195    typeflag: u8,
6196) -> Result<[u8; TAR_BLOCK_LEN], FormatError> {
6197    if path.len() > 100 {
6198        return Err(FormatError::WriterUnsupported(
6199            "ustar path exceeds name field",
6200        ));
6201    }
6202    let mut header = [0u8; TAR_BLOCK_LEN];
6203    header[0..path.len()].copy_from_slice(path);
6204    write_tar_octal(&mut header[100..108], mode as u64)?;
6205    write_tar_octal(&mut header[108..116], 0)?;
6206    write_tar_octal(&mut header[116..124], 0)?;
6207    write_tar_octal(&mut header[124..136], size)?;
6208    write_tar_octal(&mut header[136..148], mtime)?;
6209    header[148..156].fill(b' ');
6210    header[156] = typeflag;
6211    header[257..263].copy_from_slice(b"ustar\0");
6212    header[263..265].copy_from_slice(b"00");
6213    let checksum = header.iter().map(|byte| *byte as u32).sum::<u32>() as u64;
6214    write_tar_checksum(&mut header[148..156], checksum)?;
6215    Ok(header)
6216}
6217
6218fn write_tar_octal(field: &mut [u8], value: u64) -> Result<(), FormatError> {
6219    let digits = format!("{value:o}");
6220    if digits.len() + 1 > field.len() {
6221        return Err(FormatError::WriterUnsupported("tar octal field overflow"));
6222    }
6223    field.fill(0);
6224    let padding = field.len() - 1 - digits.len();
6225    for byte in &mut field[..padding] {
6226        *byte = b'0';
6227    }
6228    field[padding..padding + digits.len()].copy_from_slice(digits.as_bytes());
6229    Ok(())
6230}
6231
6232fn write_tar_checksum(field: &mut [u8], value: u64) -> Result<(), FormatError> {
6233    let digits = format!("{value:06o}");
6234    if digits.len() != 6 {
6235        return Err(FormatError::WriterUnsupported(
6236            "tar checksum field overflow",
6237        ));
6238    }
6239    field[0..6].copy_from_slice(digits.as_bytes());
6240    field[6] = 0;
6241    field[7] = b' ';
6242    Ok(())
6243}
6244
6245fn member_frame_range(
6246    member_index: usize,
6247    frames: &[PayloadFrame],
6248) -> Result<(u64, u32), FormatError> {
6249    let first = frames
6250        .iter()
6251        .find(|frame| frame.member_index == member_index)
6252        .map(|frame| frame.frame_index)
6253        .ok_or(FormatError::WriterInvariant("member frame is missing"))?;
6254    let count = frames
6255        .iter()
6256        .filter(|frame| frame.member_index == member_index)
6257        .count();
6258    Ok((first, u32_len(count, "FileEntry.frame_count")?))
6259}
6260
6261fn envelope_frame_range(
6262    envelope_index: u64,
6263    frames: &[PayloadFrame],
6264) -> Result<(u64, u32), FormatError> {
6265    let first = frames
6266        .iter()
6267        .find(|frame| frame.envelope_index == envelope_index)
6268        .map(|frame| frame.frame_index)
6269        .ok_or(FormatError::WriterInvariant("envelope frame is missing"))?;
6270    let count = frames
6271        .iter()
6272        .filter(|frame| frame.envelope_index == envelope_index)
6273        .count();
6274    Ok((first, u32_len(count, "EnvelopeEntry.frame_count")?))
6275}
6276
6277fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
6278    let digest = Sha256::digest(bytes);
6279    let mut out = [0u8; 32];
6280    out.copy_from_slice(&digest);
6281    out
6282}
6283
6284fn padding_to_512(len: usize) -> usize {
6285    let remainder = len % TAR_BLOCK_LEN;
6286    if remainder == 0 {
6287        0
6288    } else {
6289        TAR_BLOCK_LEN - remainder
6290    }
6291}
6292
6293fn padding_to_512_u64(len: u64) -> u64 {
6294    let remainder = len % TAR_BLOCK_LEN as u64;
6295    if remainder == 0 {
6296        0
6297    } else {
6298        TAR_BLOCK_LEN as u64 - remainder
6299    }
6300}
6301
6302fn table_offset(len: usize, cursor: usize) -> Result<u32, FormatError> {
6303    if len == 0 {
6304        Ok(0)
6305    } else {
6306        u32_len(cursor, "table offset")
6307    }
6308}
6309
6310fn u32_len(value: usize, field: &'static str) -> Result<u32, FormatError> {
6311    u32::try_from(value).map_err(|_| FormatError::WriterUnsupported(field))
6312}
6313
6314fn to_usize_writer(value: u64, field: &'static str) -> Result<usize, FormatError> {
6315    usize::try_from(value).map_err(|_| FormatError::WriterUnsupported(field))
6316}
6317
6318fn checked_usize_add(lhs: usize, rhs: usize, field: &'static str) -> Result<usize, FormatError> {
6319    lhs.checked_add(rhs)
6320        .ok_or(FormatError::WriterUnsupported(field))
6321}
6322
6323fn checked_u64_add(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
6324    lhs.checked_add(rhs)
6325        .ok_or(FormatError::WriterUnsupported(field))
6326}
6327
6328#[cfg(test)]
6329mod tests {
6330    use super::*;
6331    use crate::crypto::{verify_hmac, Subkeys};
6332    use crate::metadata::{DirectoryHintTable, MetadataLimits};
6333    use crate::reader::open_archive;
6334    use crate::tar_model::parse_tar_member_group;
6335    use crate::wire::{CriticalRecoveryLocator, CryptoHeader};
6336    use std::cell::RefCell;
6337    use std::io::{self, Read};
6338    use std::rc::Rc;
6339
6340    #[test]
6341    fn writer_defaults_use_v41_sizing_and_parallel_mode() {
6342        let options = WriterOptions::default();
6343
6344        assert_eq!(options.chunk_size, 256 * 1024);
6345        assert_eq!(options.envelope_target_size, 1024 * 1024);
6346        assert_eq!(options.block_size, 64 * 1024);
6347        assert_eq!(options.stripe_width, 8);
6348        assert_eq!(options.volume_loss_tolerance, 1);
6349        assert_eq!(options.fec_data_shards, 224);
6350        assert_eq!(options.index_fec_data_shards, 16);
6351        assert_eq!(
6352            options.index_root_fec_data_shards,
6353            MIN_INDEX_ROOT_FEC_DATA_SHARDS
6354        );
6355        assert_eq!(options.bit_rot_buffer_pct, 5);
6356        assert_eq!(options.jobs, default_jobs());
6357        assert!(options.jobs >= 1);
6358    }
6359
6360    #[test]
6361    fn emission_state_collects_data_leaf_hashes_only_for_root_auth() {
6362        let options = single_volume_metadata_test_options();
6363        let archive_uuid = [1u8; 16];
6364        let session_id = [2u8; 16];
6365        let crypto_header = b"test crypto header";
6366
6367        let mut unsigned_sink = MemoryArchiveSink::default();
6368        let unsigned = begin_writer_emission_state(
6369            &mut unsigned_sink,
6370            options,
6371            crypto_header,
6372            archive_uuid,
6373            session_id,
6374            false,
6375        )
6376        .unwrap();
6377        assert!(unsigned.data_leaf_hashes.is_none());
6378
6379        let mut signed_sink = MemoryArchiveSink::default();
6380        let signed = begin_writer_emission_state(
6381            &mut signed_sink,
6382            options,
6383            crypto_header,
6384            archive_uuid,
6385            session_id,
6386            true,
6387        )
6388        .unwrap();
6389        assert_eq!(signed.data_leaf_hashes.as_deref(), Some([].as_slice()));
6390    }
6391
6392    #[test]
6393    fn ordered_envelope_serializes_zero_parity_without_root_auth_leaf_collection() {
6394        let options = plan_writer_options(WriterOptions {
6395            stripe_width: 1,
6396            volume_loss_tolerance: 0,
6397            bit_rot_buffer_pct: 0,
6398            aead_algo: AeadAlgo::None,
6399            ..WriterOptions::default()
6400        })
6401        .unwrap();
6402        let payload = b"fast path payload".to_vec();
6403        let extent = ObjectExtent::new(
6404            11,
6405            plan_encrypted_object(
6406                payload.len(),
6407                options.fec_data_shards,
6408                options.fec_parity_shards,
6409                options,
6410            )
6411            .unwrap(),
6412        )
6413        .unwrap();
6414        assert_eq!(extent.parity_block_count, 0);
6415        let subkeys = Subkeys::unencrypted_placeholder();
6416
6417        let result = build_ordered_envelope_result(
6418            OrderedEnvelopeJob {
6419                envelope_index: 3,
6420                plaintext: payload.clone(),
6421                extent,
6422                collect_data_leaf_hashes: false,
6423            },
6424            &subkeys,
6425            options,
6426            [1u8; 16],
6427            [2u8; 16],
6428        )
6429        .unwrap();
6430
6431        match result.records {
6432            OrderedEnvelopeRecords::Serialized(records) => {
6433                assert_eq!(records.len(), extent.data_block_count as usize);
6434                let parsed =
6435                    BlockRecord::parse(&records[0].bytes, options.block_size as usize).unwrap();
6436                assert_eq!(parsed.block_index, extent.first_block_index);
6437                assert_eq!(parsed.kind, BlockKind::PayloadData);
6438                assert!(parsed.is_last_data());
6439            }
6440            OrderedEnvelopeRecords::Materialized(_) => {
6441                panic!("zero-parity unsigned envelope should use serialized records")
6442            }
6443        }
6444
6445        let result = build_ordered_envelope_result(
6446            OrderedEnvelopeJob {
6447                envelope_index: 3,
6448                plaintext: payload,
6449                extent,
6450                collect_data_leaf_hashes: true,
6451            },
6452            &subkeys,
6453            options,
6454            [1u8; 16],
6455            [2u8; 16],
6456        )
6457        .unwrap();
6458
6459        match result.records {
6460            OrderedEnvelopeRecords::Materialized(records) => {
6461                assert_eq!(records.len(), extent.data_block_count as usize);
6462            }
6463            OrderedEnvelopeRecords::Serialized(_) => {
6464                panic!("root-auth leaf collection requires materialized records")
6465            }
6466        }
6467    }
6468
6469    #[test]
6470    fn writer_options_reject_zero_jobs() {
6471        let err = plan_writer_options(WriterOptions {
6472            jobs: 0,
6473            ..WriterOptions::default()
6474        })
6475        .unwrap_err();
6476
6477        assert_eq!(
6478            err,
6479            FormatError::WriterUnsupported("jobs must be at least 1")
6480        );
6481    }
6482
6483    #[test]
6484    fn production_writer_defaults_generate_distinct_v4_identities() {
6485        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
6486        let first = write_archive(&[], &master_key, WriterOptions::default()).unwrap();
6487        let second = write_archive(&[], &master_key, WriterOptions::default()).unwrap();
6488
6489        assert_ne!(first.archive_uuid, [0u8; 16]);
6490        assert_ne!(first.session_id, [0u8; 16]);
6491        assert_ne!(second.archive_uuid, [0u8; 16]);
6492        assert_ne!(second.session_id, [0u8; 16]);
6493        assert_ne!(first.archive_uuid, first.session_id);
6494        assert_ne!(first.archive_uuid, second.archive_uuid);
6495        assert_ne!(first.session_id, second.session_id);
6496
6497        for raw in [
6498            first.archive_uuid,
6499            first.session_id,
6500            second.archive_uuid,
6501            second.session_id,
6502        ] {
6503            let id = Uuid::from_bytes(raw);
6504            assert_eq!(id.get_version_num(), 4);
6505        }
6506
6507        let deterministic = WriterOptions {
6508            archive_uuid: Some([0x44; 16]),
6509            session_id: Some([0x55; 16]),
6510            ..WriterOptions::default()
6511        };
6512        let fixture = write_archive(&[], &master_key, deterministic).unwrap();
6513        assert_eq!(fixture.archive_uuid, [0x44; 16]);
6514        assert_eq!(fixture.session_id, [0x55; 16]);
6515    }
6516
6517    #[test]
6518    fn writer_partitions_multiple_default_sized_index_shards() {
6519        let members = (0..=DEFAULT_FILES_PER_INDEX_SHARD)
6520            .map(|idx| TarMember {
6521                path: format!("file-{idx:05}.txt").into_bytes(),
6522                tar_member_group_start: idx as u64 * 512,
6523                tar_member_group_size: 512,
6524                file_data_size: 0,
6525                mode: 0o644,
6526                mtime: 0,
6527            })
6528            .collect::<Vec<_>>();
6529
6530        let shards = partition_file_rows(sorted_file_rows(&members)).unwrap();
6531
6532        assert_eq!(shards.len(), 2);
6533        assert_eq!(shards[0].len(), DEFAULT_FILES_PER_INDEX_SHARD);
6534        assert_eq!(shards[1].len(), 1);
6535    }
6536
6537    #[test]
6538    fn writer_extends_shard_for_bounded_hash_prefix_run() {
6539        let mut rows = Vec::new();
6540        rows.extend((0..9_000).map(|idx| test_file_row(idx, [0u8; 8])));
6541        rows.extend((9_000..54_000).map(|idx| test_file_row(idx, [1u8; 8])));
6542        rows.push(test_file_row(54_000, [2u8; 8]));
6543
6544        let shards = partition_file_rows(rows).unwrap();
6545
6546        assert_eq!(shards.len(), 2);
6547        assert_eq!(shards[0].len(), 54_000);
6548        assert!(shards[0]
6549            .iter()
6550            .skip(9_000)
6551            .all(|row| row.path_hash == [1u8; 8]));
6552        assert_eq!(shards[1][0].path_hash, [2u8; 8]);
6553    }
6554
6555    #[test]
6556    fn writer_splits_oversized_hash_prefix_run_at_writer_ceiling() {
6557        let rows = (0..MAX_HASH_PREFIX_RUN_FILES + 1)
6558            .map(|idx| test_file_row(idx, [7u8; 8]))
6559            .collect::<Vec<_>>();
6560
6561        let shards = partition_file_rows(rows).unwrap();
6562
6563        assert_eq!(shards.len(), 2);
6564        assert_eq!(shards[0].len(), MAX_HASH_PREFIX_RUN_FILES);
6565        assert_eq!(shards[1].len(), 1);
6566    }
6567
6568    #[test]
6569    fn writer_builds_directory_hint_rows_for_ancestor_directories() {
6570        let shard_rows = vec![
6571            vec![FileRow {
6572                path_hash: hash_prefix(b"a/b/one.txt"),
6573                path: b"a/b/one.txt".to_vec(),
6574                member_index: 0,
6575                member: TarMember {
6576                    path: b"a/b/one.txt".to_vec(),
6577                    tar_member_group_start: 0,
6578                    tar_member_group_size: 512,
6579                    file_data_size: 0,
6580                    mode: 0o644,
6581                    mtime: 0,
6582                },
6583            }],
6584            vec![FileRow {
6585                path_hash: hash_prefix(b"a/c/two.txt"),
6586                path: b"a/c/two.txt".to_vec(),
6587                member_index: 1,
6588                member: TarMember {
6589                    path: b"a/c/two.txt".to_vec(),
6590                    tar_member_group_start: 512,
6591                    tar_member_group_size: 512,
6592                    file_data_size: 0,
6593                    mode: 0o644,
6594                    mtime: 0,
6595                },
6596            }],
6597        ];
6598
6599        let options = plan_writer_options(WriterOptions::default()).unwrap();
6600        let planned = build_directory_hint_plaintexts(&shard_rows, options).unwrap();
6601        assert_eq!(planned.len(), 1);
6602        let locating = DirectoryHintShardEntry {
6603            hint_shard_index: planned[0].hint_shard_index,
6604            first_dir_hash: planned[0].first_dir_hash,
6605            last_dir_hash: planned[0].last_dir_hash,
6606            first_block_index: 0,
6607            data_block_count: 1,
6608            parity_block_count: 0,
6609            encrypted_size: 4096,
6610            decompressed_size: planned[0].plaintext.len() as u32,
6611            entry_count: planned[0].entry_count,
6612        };
6613        let table = DirectoryHintTable::parse(
6614            &planned[0].plaintext,
6615            &locating,
6616            2,
6617            MetadataLimits::default(),
6618        )
6619        .unwrap();
6620
6621        let root = table.lookup_directory_index(b"").unwrap();
6622        assert_eq!(table.shard_rows_for_entry(root).unwrap(), &[0, 1]);
6623        let a = table.lookup_directory_index(b"a").unwrap();
6624        assert_eq!(table.shard_rows_for_entry(a).unwrap(), &[0, 1]);
6625        let ab = table.lookup_directory_index(b"a/b").unwrap();
6626        assert_eq!(table.shard_rows_for_entry(ab).unwrap(), &[0]);
6627    }
6628
6629    #[test]
6630    fn directory_hints_are_required_only_above_v41_threshold() {
6631        assert!(!should_emit_directory_hints(0));
6632        assert!(!should_emit_directory_hints(
6633            DIRECTORY_HINT_REQUIRED_FILE_COUNT
6634        ));
6635        assert!(should_emit_directory_hints(
6636            DIRECTORY_HINT_REQUIRED_FILE_COUNT + 1
6637        ));
6638    }
6639
6640    #[test]
6641    fn regular_file_writer_uses_local_pax_path_for_long_and_non_ascii_paths() {
6642        let long_path = format!("dir/{}.txt", "a".repeat(120));
6643        let unicode_path = "unicode/e\u{301}.txt";
6644        let files = [
6645            RegularFile::new(&long_path, b"long path"),
6646            RegularFile::new(unicode_path, b"unicode path"),
6647        ];
6648
6649        let (tar_stream, members) = build_tar_stream(&files, 4096).unwrap();
6650
6651        for (member, expected_path, expected_data) in [
6652            (&members[0], long_path.as_bytes(), b"long path".as_slice()),
6653            (
6654                &members[1],
6655                "unicode/\u{e9}.txt".as_bytes(),
6656                b"unicode path".as_slice(),
6657            ),
6658        ] {
6659            let start = member.tar_member_group_start as usize;
6660            let end = start + member.tar_member_group_size as usize;
6661            let group = &tar_stream[start..end];
6662            assert_eq!(group[156], b'x');
6663            let parsed = parse_tar_member_group(group, 4096).unwrap();
6664            assert_eq!(parsed.path, expected_path);
6665            assert_eq!(parsed.data, expected_data);
6666        }
6667    }
6668
6669    #[test]
6670    fn regular_file_writer_emits_no_global_metadata_or_tar_eof() {
6671        let long_path = format!("dir/{}.txt", "a".repeat(120));
6672        let files = [
6673            RegularFile::new("plain.txt", b"plain contents"),
6674            RegularFile::new(&long_path, b"long path contents"),
6675        ];
6676
6677        let (tar_stream, members) = build_tar_stream(&files, 4096).unwrap();
6678
6679        let member_bytes = members
6680            .iter()
6681            .map(|member| member.tar_member_group_size)
6682            .sum::<u64>();
6683        assert_eq!(tar_stream.len() as u64, member_bytes);
6684        assert!(!tar_stream[tar_stream.len() - TAR_BLOCK_LEN * 2..]
6685            .chunks(TAR_BLOCK_LEN)
6686            .all(|block| block.iter().all(|byte| *byte == 0)));
6687
6688        for member in members {
6689            let start = member.tar_member_group_start as usize;
6690            let end = start + member.tar_member_group_size as usize;
6691            assert_path_specific_member_group(&tar_stream[start..end]);
6692        }
6693    }
6694
6695    #[test]
6696    fn regular_file_writer_round_trips_mode_and_mtime() {
6697        let group =
6698            build_regular_file_member_group(b"script.sh", b"#!/bin/sh\n", 0o755, 1_700_000_000)
6699                .unwrap();
6700
6701        let parsed = parse_tar_member_group(&group, 4096).unwrap();
6702
6703        assert_eq!(parsed.mode, 0o755);
6704        assert_eq!(parsed.mtime, 1_700_000_000);
6705    }
6706
6707    #[test]
6708    fn writer_splits_large_payload_across_seekable_envelopes() {
6709        let master_key = MasterKey::from_raw_key(&[8u8; 32]).unwrap();
6710        let data = deterministic_bytes(2 * 1024 * 1024);
6711        let archive = write_archive(
6712            &[RegularFile::new("large.bin", &data)],
6713            &master_key,
6714            WriterOptions {
6715                stripe_width: 1,
6716                volume_loss_tolerance: 0,
6717                bit_rot_buffer_pct: 0,
6718                ..WriterOptions::default()
6719            },
6720        )
6721        .unwrap();
6722        let opened = open_archive(&archive.bytes, &master_key).unwrap();
6723
6724        assert_eq!(opened.list_files().unwrap()[0].path, "large.bin");
6725        assert_eq!(opened.extract_file("large.bin").unwrap(), Some(data));
6726        opened.verify().unwrap();
6727        assert!(opened.index_root.header.envelope_count > 1);
6728    }
6729
6730    #[test]
6731    fn split_member_frames_carry_exact_boundary_flags() {
6732        let data = deterministic_bytes(12 * 1024);
6733        let files = [RegularFile::new("large.bin", &data)];
6734        let options = WriterOptions {
6735            chunk_size: 1024,
6736            envelope_target_size: 64 * 1024,
6737            stripe_width: 1,
6738            volume_loss_tolerance: 0,
6739            bit_rot_buffer_pct: 0,
6740            ..WriterOptions::default()
6741        };
6742        let (tar_stream, members) = build_tar_stream(&files, options.max_path_length).unwrap();
6743        let (_, frames) = build_payload_envelopes(&tar_stream, &members, options, None).unwrap();
6744
6745        assert!(frames.len() > 2);
6746        assert_eq!(frames.first().unwrap().flags, 0x0000_0001);
6747        assert_eq!(frames.last().unwrap().flags, 0x0000_0002);
6748        assert!(frames[1..frames.len() - 1]
6749            .iter()
6750            .all(|frame| frame.flags == 0));
6751    }
6752
6753    #[test]
6754    fn writes_empty_archive_with_authentic_bootstrap_structures() {
6755        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
6756        let archive = write_empty_archive(&master_key).unwrap();
6757        let bytes = archive.bytes;
6758
6759        let volume_header = VolumeHeader::parse(&bytes[..VOLUME_HEADER_LEN]).unwrap();
6760        assert_eq!(volume_header.archive_uuid, archive.archive_uuid);
6761        assert_eq!(volume_header.session_id, archive.session_id);
6762
6763        let crypto_start = VOLUME_HEADER_LEN;
6764        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
6765        let crypto_header = CryptoHeader::parse(
6766            &bytes[crypto_start..crypto_end],
6767            volume_header.crypto_header_length,
6768        )
6769        .unwrap();
6770        let subkeys =
6771            Subkeys::derive(&master_key, &archive.archive_uuid, &archive.session_id).unwrap();
6772        verify_hmac(
6773            HmacDomain::CryptoHeader,
6774            &subkeys.mac_key,
6775            &archive.archive_uuid,
6776            &archive.session_id,
6777            crypto_header.hmac_covered_bytes,
6778            &crypto_header.header_hmac,
6779        )
6780        .unwrap();
6781
6782        let locator =
6783            CriticalRecoveryLocator::parse(&bytes[bytes.len() - CRITICAL_RECOVERY_LOCATOR_LEN..])
6784                .unwrap();
6785        let trailer_offset = locator.volume_trailer_offset as usize;
6786        let trailer =
6787            VolumeTrailer::parse(&bytes[trailer_offset..trailer_offset + VOLUME_TRAILER_LEN])
6788                .unwrap();
6789        assert_eq!(trailer.bytes_written, trailer_offset as u64);
6790        verify_hmac(
6791            HmacDomain::VolumeTrailer,
6792            &subkeys.mac_key,
6793            &archive.archive_uuid,
6794            &archive.session_id,
6795            &bytes[trailer_offset..trailer_offset + 96],
6796            &trailer.trailer_hmac,
6797        )
6798        .unwrap();
6799
6800        let manifest_offset = trailer.manifest_footer_offset as usize;
6801        let manifest_end = manifest_offset + MANIFEST_FOOTER_LEN;
6802        let manifest = ManifestFooter::parse(&bytes[manifest_offset..manifest_end]).unwrap();
6803        assert_eq!(manifest.is_authoritative, 1);
6804        assert_eq!(manifest.total_volumes, DEFAULT_STRIPE_WIDTH);
6805        verify_hmac(
6806            HmacDomain::ManifestFooter,
6807            &subkeys.mac_key,
6808            &archive.archive_uuid,
6809            &archive.session_id,
6810            &bytes[manifest_offset..manifest_offset + 104],
6811            &manifest.manifest_hmac,
6812        )
6813        .unwrap();
6814    }
6815
6816    #[test]
6817    fn parity_auto_scaling_matches_v41_examples() {
6818        let options = WriterOptions {
6819            fec_data_shards: 224,
6820            stripe_width: 8,
6821            volume_loss_tolerance: 1,
6822            bit_rot_buffer_pct: 5,
6823            ..WriterOptions::default()
6824        };
6825
6826        assert_eq!(compute_parity(224, options).unwrap(), 48);
6827        assert_eq!(compute_parity(17, options).unwrap(), 5);
6828    }
6829
6830    #[test]
6831    fn parity_auto_scaling_rejects_non_convergent_budget() {
6832        let err = compute_parity(
6833            1,
6834            WriterOptions {
6835                stripe_width: 2,
6836                volume_loss_tolerance: 1,
6837                bit_rot_buffer_pct: 50,
6838                ..WriterOptions::default()
6839            },
6840        )
6841        .unwrap_err();
6842
6843        assert_eq!(
6844            err,
6845            FormatError::WriterUnsupported("parity calculation did not converge")
6846        );
6847    }
6848
6849    #[test]
6850    fn zero_parity_is_allowed_when_no_recovery_margin_is_requested() {
6851        let planned = plan_writer_options(WriterOptions {
6852            bit_rot_buffer_pct: 0,
6853            stripe_width: 1,
6854            volume_loss_tolerance: 0,
6855            fec_parity_shards: 0,
6856            index_fec_parity_shards: 0,
6857            index_root_fec_parity_shards: 0,
6858            ..WriterOptions::default()
6859        })
6860        .unwrap();
6861
6862        assert_eq!(planned.fec_parity_shards, 0);
6863        assert_eq!(planned.index_fec_parity_shards, 0);
6864        assert_eq!(planned.index_root_fec_parity_shards, 0);
6865        assert_eq!(compute_parity(1, planned).unwrap(), 0);
6866    }
6867
6868    #[test]
6869    fn index_root_data_shard_maximum_obeys_v41_minimum() {
6870        let planned = plan_writer_options(WriterOptions {
6871            index_root_fec_data_shards: 1,
6872            ..WriterOptions::default()
6873        })
6874        .unwrap();
6875
6876        assert_eq!(
6877            planned.index_root_fec_data_shards,
6878            MIN_INDEX_ROOT_FEC_DATA_SHARDS
6879        );
6880    }
6881
6882    #[test]
6883    fn metadata_class_planning_raises_index_root_class_above_default() {
6884        let options = plan_writer_options(WriterOptions {
6885            block_size: MIN_BLOCK_SIZE,
6886            index_root_fec_parity_shards: 0,
6887            bit_rot_buffer_pct: 0,
6888            ..WriterOptions::default()
6889        })
6890        .unwrap();
6891        let index_root_payload_len = payload_len_for_encrypted_data_blocks(64, options);
6892
6893        let planned =
6894            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap();
6895
6896        assert_eq!(planned.index_root.data_block_count, 64);
6897        assert_eq!(planned.options.index_root_fec_data_shards, 64);
6898        assert_eq!(
6899            planned.options.index_root_fec_parity_shards,
6900            compute_parity_u16(
6901                planned.options.index_root_fec_data_shards as u64,
6902                planned.options,
6903                "index_root_fec_parity_shards",
6904            )
6905            .unwrap()
6906        );
6907    }
6908
6909    #[test]
6910    fn single_pass_writer_predeclares_metadata_class_before_payload_streaming() {
6911        let planned = plan_single_pass_writer_options(WriterOptions {
6912            block_size: MIN_BLOCK_SIZE,
6913            stripe_width: 1,
6914            volume_loss_tolerance: 0,
6915            bit_rot_buffer_pct: 0,
6916            index_root_fec_parity_shards: 0,
6917            ..WriterOptions::default()
6918        })
6919        .unwrap();
6920
6921        assert!(planned.index_root_fec_data_shards > DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS);
6922        let index_root_payload_len = payload_len_for_encrypted_data_blocks(
6923            u32::from(planned.index_root_fec_data_shards - 1),
6924            planned,
6925        );
6926        let metadata_class =
6927            plan_index_root_metadata_class(planned, index_root_payload_len, None).unwrap();
6928
6929        assert_eq!(metadata_class.options, planned);
6930    }
6931
6932    #[test]
6933    fn metadata_class_planning_rejects_oversized_index_root() {
6934        let options = single_volume_metadata_test_options();
6935        let index_root_payload_len =
6936            payload_len_for_encrypted_data_blocks(u16::MAX as u32 + 1, options);
6937
6938        let err =
6939            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap_err();
6940
6941        assert_eq!(err, FormatError::WriterUnsupported("IndexRoot too large"));
6942    }
6943
6944    #[test]
6945    fn metadata_class_planning_rejects_index_root_u32_encrypted_size_overflow() {
6946        let options = single_volume_metadata_test_options();
6947        let index_root_payload_len = u32::MAX as usize - options.aead_algo.tag_len() + 1;
6948
6949        let err =
6950            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap_err();
6951
6952        assert_eq!(err, FormatError::WriterUnsupported("IndexRoot too large"));
6953    }
6954
6955    #[test]
6956    fn metadata_class_planning_rejects_oversized_dictionary() {
6957        let options = single_volume_metadata_test_options();
6958        let dictionary_payload_len =
6959            payload_len_for_encrypted_data_blocks(u16::MAX as u32 + 1, options);
6960
6961        let err =
6962            plan_index_root_metadata_class(options, 1, Some(dictionary_payload_len)).unwrap_err();
6963
6964        assert_eq!(
6965            err,
6966            FormatError::WriterUnsupported("dictionary object too large")
6967        );
6968    }
6969
6970    #[test]
6971    fn metadata_class_planning_rejects_gf16_total_overflow_for_dictionary() {
6972        let options = plan_writer_options(WriterOptions {
6973            block_size: MIN_BLOCK_SIZE,
6974            stripe_width: 8,
6975            volume_loss_tolerance: 1,
6976            bit_rot_buffer_pct: 5,
6977            ..WriterOptions::default()
6978        })
6979        .unwrap();
6980        let dictionary_payload_len = payload_len_for_encrypted_data_blocks(60_000, options);
6981
6982        let err =
6983            plan_index_root_metadata_class(options, 1, Some(dictionary_payload_len)).unwrap_err();
6984
6985        assert_eq!(
6986            err,
6987            FormatError::WriterUnsupported("dictionary object too large")
6988        );
6989    }
6990
6991    #[test]
6992    fn written_archive_authenticates_final_index_root_fec_class() {
6993        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
6994        let dictionary = deterministic_bytes(80 * 1024);
6995        let file = RegularFile::new("uses-dictionary.txt", b"payload");
6996        let archive = write_archive_with_dictionary(
6997            &[file],
6998            &master_key,
6999            WriterOptions {
7000                block_size: MIN_BLOCK_SIZE,
7001                stripe_width: 1,
7002                volume_loss_tolerance: 0,
7003                bit_rot_buffer_pct: 0,
7004                index_root_fec_parity_shards: 0,
7005                ..WriterOptions::default()
7006            },
7007            &dictionary,
7008        )
7009        .unwrap();
7010
7011        let volume_header = VolumeHeader::parse(&archive.bytes[..VOLUME_HEADER_LEN]).unwrap();
7012        let crypto_start = VOLUME_HEADER_LEN;
7013        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
7014        let crypto_header = CryptoHeader::parse(
7015            &archive.bytes[crypto_start..crypto_end],
7016            volume_header.crypto_header_length,
7017        )
7018        .unwrap();
7019        let subkeys =
7020            Subkeys::derive(&master_key, &archive.archive_uuid, &archive.session_id).unwrap();
7021        verify_hmac(
7022            HmacDomain::CryptoHeader,
7023            &subkeys.mac_key,
7024            &archive.archive_uuid,
7025            &archive.session_id,
7026            crypto_header.hmac_covered_bytes,
7027            &crypto_header.header_hmac,
7028        )
7029        .unwrap();
7030
7031        assert!(crypto_header.fixed.index_root_fec_data_shards > MIN_INDEX_ROOT_FEC_DATA_SHARDS);
7032        assert_eq!(crypto_header.fixed.index_root_fec_parity_shards, 0);
7033        let opened = open_archive(&archive.bytes, &master_key).unwrap();
7034        assert_eq!(
7035            opened.extract_file("uses-dictionary.txt").unwrap(),
7036            Some(b"payload".to_vec())
7037        );
7038        opened.verify().unwrap();
7039    }
7040
7041    #[test]
7042    fn object_parity_uses_per_object_recurrence_even_with_larger_class_max() {
7043        let options = WriterOptions {
7044            bit_rot_buffer_pct: 0,
7045            stripe_width: 1,
7046            volume_loss_tolerance: 0,
7047            fec_parity_shards: 1,
7048            ..WriterOptions::default()
7049        };
7050
7051        assert_eq!(compute_object_parity(1, options, 1).unwrap(), 0);
7052    }
7053
7054    #[test]
7055    fn object_total_shards_obeys_reed_solomon_limit() {
7056        assert!(validate_object_shard_total(65_535, 0).is_ok());
7057        assert_eq!(
7058            validate_object_shard_total(65_535, 1).unwrap_err(),
7059            FormatError::WriterUnsupported("encrypted object exceeds ReedSolomonGF16 shard limit")
7060        );
7061    }
7062
7063    #[test]
7064    fn argon2id_kdf_serialization_rejects_memory_requirement_overflow() {
7065        assert_eq!(
7066            serialize_kdf_params(&KdfParams::Argon2id {
7067                t_cost: 1,
7068                m_cost_kib: u32::MAX,
7069                parallelism: u32::MAX,
7070                salt: b"12345678".to_vec(),
7071            })
7072            .unwrap_err(),
7073            FormatError::InvalidKdfParams("m_cost_kib requirement overflow")
7074        );
7075    }
7076
7077    #[test]
7078    fn writer_options_reject_reader_resource_cap_excesses() {
7079        assert_eq!(
7080            plan_writer_options(WriterOptions {
7081                stripe_width: crate::format::READER_MAX_STRIPE_WIDTH + 1,
7082                volume_loss_tolerance: 0,
7083                ..WriterOptions::default()
7084            })
7085            .unwrap_err(),
7086            FormatError::ReaderResourceLimitExceeded {
7087                field: "stripe_width",
7088                cap: crate::format::READER_MAX_STRIPE_WIDTH as u64,
7089                actual: crate::format::READER_MAX_STRIPE_WIDTH as u64 + 1,
7090            }
7091        );
7092        assert_eq!(
7093            plan_writer_options(WriterOptions {
7094                block_size: crate::format::READER_MAX_BLOCK_SIZE + 2,
7095                ..WriterOptions::default()
7096            })
7097            .unwrap_err(),
7098            FormatError::ReaderResourceLimitExceeded {
7099                field: "block_size",
7100                cap: crate::format::READER_MAX_BLOCK_SIZE as u64,
7101                actual: crate::format::READER_MAX_BLOCK_SIZE as u64 + 2,
7102            }
7103        );
7104        assert_eq!(
7105            plan_writer_options(WriterOptions {
7106                chunk_size: crate::format::READER_MAX_CHUNK_SIZE + 1,
7107                envelope_target_size: crate::format::READER_MAX_CHUNK_SIZE + 1,
7108                ..WriterOptions::default()
7109            })
7110            .unwrap_err(),
7111            FormatError::ReaderResourceLimitExceeded {
7112                field: "chunk_size",
7113                cap: crate::format::READER_MAX_CHUNK_SIZE as u64,
7114                actual: crate::format::READER_MAX_CHUNK_SIZE as u64 + 1,
7115            }
7116        );
7117        assert_eq!(
7118            plan_writer_options(WriterOptions {
7119                max_path_length: crate::format::READER_MAX_PATH_LENGTH + 1,
7120                ..WriterOptions::default()
7121            })
7122            .unwrap_err(),
7123            FormatError::ReaderResourceLimitExceeded {
7124                field: "max_path_length",
7125                cap: crate::format::READER_MAX_PATH_LENGTH as u64,
7126                actual: crate::format::READER_MAX_PATH_LENGTH as u64 + 1,
7127            }
7128        );
7129        assert_eq!(
7130            plan_writer_options(WriterOptions {
7131                bit_rot_buffer_pct: 0,
7132                stripe_width: 1,
7133                volume_loss_tolerance: 0,
7134                fec_data_shards: crate::format::READER_MAX_FEC_CLASS_SHARDS as u16 + 1,
7135                ..WriterOptions::default()
7136            })
7137            .unwrap_err(),
7138            FormatError::ReaderResourceLimitExceeded {
7139                field: "fec_data_shards + fec_parity_shards",
7140                cap: crate::format::READER_MAX_FEC_CLASS_SHARDS as u64,
7141                actual: crate::format::READER_MAX_FEC_CLASS_SHARDS as u64 + 1,
7142            }
7143        );
7144    }
7145
7146    #[test]
7147    fn root_auth_writer_config_rejects_reader_cap_excess_before_authenticator() {
7148        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
7149        let mut authenticator_called = false;
7150        let err = write_archive_with_root_auth(
7151            &[RegularFile::new("signed.txt", b"payload")],
7152            &master_key,
7153            single_volume_metadata_test_options(),
7154            RootAuthWriterConfig {
7155                authenticator_id: 1,
7156                signer_identity_type: 1,
7157                signer_identity: b"signer",
7158                authenticator_value_length: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN + 1,
7159            },
7160            |_| {
7161                authenticator_called = true;
7162                Ok(Vec::new())
7163            },
7164        )
7165        .unwrap_err();
7166
7167        assert!(!authenticator_called);
7168        assert_eq!(
7169            err,
7170            FormatError::ReaderResourceLimitExceeded {
7171                field: "RootAuthFooterV1 authenticator value length",
7172                cap: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as u64,
7173                actual: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as u64 + 1,
7174            }
7175        );
7176    }
7177
7178    #[test]
7179    fn root_auth_writer_accepts_128_kib_authenticator_value() {
7180        let master_key = MasterKey::from_raw_key(&[8u8; 32]).unwrap();
7181        let authenticator_value = vec![0x5a; READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN as usize];
7182        let expected_value = authenticator_value.clone();
7183        let archive = write_archive_with_root_auth(
7184            &[RegularFile::new("signed.txt", b"payload")],
7185            &master_key,
7186            single_volume_metadata_test_options(),
7187            RootAuthWriterConfig {
7188                authenticator_id: 0xcafe,
7189                signer_identity_type: 1,
7190                signer_identity: b"certificate-profile-signer",
7191                authenticator_value_length: READER_MAX_ROOT_AUTH_AUTHENTICATOR_VALUE_LEN,
7192            },
7193            |_| Ok(authenticator_value.clone()),
7194        )
7195        .unwrap();
7196
7197        let opened = open_archive(&archive.bytes, &master_key).unwrap();
7198        let footer = opened.root_auth_footer.as_ref().unwrap();
7199        assert_eq!(footer.authenticator_id, 0xcafe);
7200        assert_eq!(
7201            footer.authenticator_value.as_slice(),
7202            expected_value.as_slice()
7203        );
7204
7205        let verification = opened
7206            .verify_root_auth_with(|footer, _| {
7207                Ok(footer.authenticator_id == 0xcafe
7208                    && footer.authenticator_value.as_slice() == expected_value.as_slice())
7209            })
7210            .unwrap();
7211        assert_eq!(verification.authenticator_id, 0xcafe);
7212    }
7213
7214    #[test]
7215    fn streaming_writer_sink_round_trips_archive() {
7216        let files = [
7217            RegularFile::new("alpha.txt", b"alpha"),
7218            RegularFile::new("nested/beta.txt", b"beta payload"),
7219        ];
7220        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
7221        let mut sink = MemoryArchiveSink::default();
7222
7223        let summary = write_archive_sources_to_sink(
7224            &files,
7225            &master_key,
7226            single_volume_metadata_test_options(),
7227            None,
7228            &KdfParams::Raw,
7229            None,
7230            None,
7231            &mut sink,
7232        )
7233        .unwrap();
7234
7235        assert_eq!(summary.volume_count, 1);
7236        let opened = crate::reader::open_archive(&sink.volumes[0], &master_key).unwrap();
7237        assert_eq!(
7238            opened.extract_file("nested/beta.txt").unwrap(),
7239            Some(b"beta payload".to_vec())
7240        );
7241    }
7242
7243    #[test]
7244    fn streaming_writer_bounds_source_reads_and_sink_writes_for_large_file() {
7245        let file_size = 3 * 1024 * 1024;
7246        let stats = Rc::new(RefCell::new(GeneratedSourceStats::default()));
7247        let file = GeneratedFileSource {
7248            path: "large/generated.bin",
7249            len: file_size,
7250            stats: Rc::clone(&stats),
7251        };
7252        let master_key = MasterKey::from_raw_key(&[3u8; 32]).unwrap();
7253        let options = plan_writer_options(WriterOptions {
7254            block_size: MIN_BLOCK_SIZE,
7255            chunk_size: 16 * 1024,
7256            envelope_target_size: 64 * 1024,
7257            stripe_width: 1,
7258            volume_loss_tolerance: 0,
7259            bit_rot_buffer_pct: 0,
7260            fec_parity_shards: 0,
7261            index_fec_parity_shards: 0,
7262            index_root_fec_parity_shards: 0,
7263            ..WriterOptions::default()
7264        })
7265        .unwrap();
7266        let mut sink = TrackingArchiveSink::default();
7267
7268        let summary = write_archive_sources_to_sink_single_pass(
7269            &[file],
7270            &master_key,
7271            options,
7272            &KdfParams::Raw,
7273            None,
7274            None,
7275            &mut sink,
7276        )
7277        .unwrap();
7278
7279        let stats = stats.borrow();
7280        assert_eq!(stats.open_count, 1);
7281        assert_eq!(stats.total_read, file_size as u64);
7282        assert!(stats.max_read_request <= options.chunk_size as usize);
7283        assert_eq!(summary.volume_count, 1);
7284        assert_eq!(summary.archive_bytes, sink.volume_bytes.iter().sum());
7285        assert_eq!(
7286            summary.bootstrap_sidecar_bytes,
7287            sink.bootstrap_sidecar_bytes
7288        );
7289        assert!(sink.max_write_len <= 128 * 1024);
7290    }
7291
7292    #[test]
7293    fn sink_writer_progress_reports_source_bytes_during_multi_pass_write() {
7294        let file_size = 512 * 1024;
7295        let stats = Rc::new(RefCell::new(GeneratedSourceStats::default()));
7296        let file = GeneratedFileSource {
7297            path: "large/generated.bin",
7298            len: file_size,
7299            stats: Rc::clone(&stats),
7300        };
7301        let master_key = MasterKey::from_raw_key(&[4u8; 32]).unwrap();
7302        let options = WriterOptions {
7303            block_size: MIN_BLOCK_SIZE,
7304            chunk_size: 16 * 1024,
7305            envelope_target_size: 64 * 1024,
7306            stripe_width: 1,
7307            volume_loss_tolerance: 0,
7308            bit_rot_buffer_pct: 0,
7309            fec_parity_shards: 0,
7310            index_fec_parity_shards: 0,
7311            index_root_fec_parity_shards: 0,
7312            ..WriterOptions::default()
7313        };
7314        let mut sink = MemoryArchiveSink::default();
7315        let mut progress_events = Vec::new();
7316        let mut progress = |archive_path: &str, bytes: u64| {
7317            progress_events.push((archive_path.to_owned(), bytes));
7318        };
7319
7320        let summary = write_archive_sources_to_sink_with_progress(
7321            &[file],
7322            &master_key,
7323            options,
7324            None,
7325            &KdfParams::Raw,
7326            None,
7327            None,
7328            &mut sink,
7329            &mut progress,
7330        )
7331        .unwrap();
7332
7333        let reported_bytes = progress_events.iter().map(|(_, bytes)| *bytes).sum::<u64>();
7334        let stats = stats.borrow();
7335        assert!(stats.open_count > 1);
7336        assert_eq!(reported_bytes, file_size as u64);
7337        assert!(progress_events.len() > 1);
7338        assert!(progress_events
7339            .iter()
7340            .all(|(path, _)| path == "large/generated.bin"));
7341        assert_eq!(summary.volume_count, 1);
7342        assert!(!sink.volumes.is_empty());
7343    }
7344
7345    #[derive(Default)]
7346    struct GeneratedSourceStats {
7347        open_count: usize,
7348        total_read: u64,
7349        max_read_request: usize,
7350    }
7351
7352    struct GeneratedFileSource {
7353        path: &'static str,
7354        len: usize,
7355        stats: Rc<RefCell<GeneratedSourceStats>>,
7356    }
7357
7358    impl RegularFileSource for GeneratedFileSource {
7359        fn archive_path(&self) -> &str {
7360            self.path
7361        }
7362
7363        fn file_data_size(&self) -> u64 {
7364            self.len as u64
7365        }
7366
7367        fn mode(&self) -> u32 {
7368            0o644
7369        }
7370
7371        fn mtime(&self) -> u64 {
7372            0
7373        }
7374
7375        fn open(&self) -> Result<Box<dyn Read + '_>, ArchiveWriteError> {
7376            self.stats.borrow_mut().open_count += 1;
7377            Ok(Box::new(GeneratedReader {
7378                remaining: self.len,
7379                position: 0,
7380                stats: Rc::clone(&self.stats),
7381            }))
7382        }
7383    }
7384
7385    struct GeneratedReader {
7386        remaining: usize,
7387        position: usize,
7388        stats: Rc<RefCell<GeneratedSourceStats>>,
7389    }
7390
7391    impl Read for GeneratedReader {
7392        fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
7393            if self.remaining == 0 {
7394                return Ok(0);
7395            }
7396            let count = out.len().min(self.remaining);
7397            for (offset, byte) in out[..count].iter_mut().enumerate() {
7398                let position = self.position + offset;
7399                *byte = position.wrapping_mul(31).wrapping_add(17) as u8;
7400            }
7401            self.position += count;
7402            self.remaining -= count;
7403            let mut stats = self.stats.borrow_mut();
7404            stats.total_read += count as u64;
7405            stats.max_read_request = stats.max_read_request.max(out.len());
7406            Ok(count)
7407        }
7408    }
7409
7410    #[derive(Default)]
7411    struct TrackingArchiveSink {
7412        volume_bytes: Vec<u64>,
7413        bootstrap_sidecar_bytes: u64,
7414        max_write_len: usize,
7415    }
7416
7417    impl ArchiveWriteSink for TrackingArchiveSink {
7418        fn begin_archive(&mut self, volume_count: usize) -> Result<(), ArchiveWriteError> {
7419            self.volume_bytes = vec![0; volume_count];
7420            self.bootstrap_sidecar_bytes = 0;
7421            self.max_write_len = 0;
7422            Ok(())
7423        }
7424
7425        fn write_volume(
7426            &mut self,
7427            volume_index: usize,
7428            bytes: &[u8],
7429        ) -> Result<(), ArchiveWriteError> {
7430            let volume =
7431                self.volume_bytes
7432                    .get_mut(volume_index)
7433                    .ok_or(FormatError::WriterInvariant(
7434                        "tracking sink volume index is out of bounds",
7435                    ))?;
7436            *volume += bytes.len() as u64;
7437            self.max_write_len = self.max_write_len.max(bytes.len());
7438            Ok(())
7439        }
7440
7441        fn write_bootstrap_sidecar(&mut self, bytes: &[u8]) -> Result<(), ArchiveWriteError> {
7442            self.bootstrap_sidecar_bytes += bytes.len() as u64;
7443            self.max_write_len = self.max_write_len.max(bytes.len());
7444            Ok(())
7445        }
7446    }
7447
7448    fn deterministic_bytes(len: usize) -> Vec<u8> {
7449        let mut state = 0x4d41_4d45u32;
7450        let mut out = Vec::with_capacity(len);
7451        for _ in 0..len {
7452            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
7453            out.push((state >> 24) as u8);
7454        }
7455        out
7456    }
7457
7458    fn single_volume_metadata_test_options() -> WriterOptions {
7459        plan_writer_options(WriterOptions {
7460            block_size: MIN_BLOCK_SIZE,
7461            stripe_width: 1,
7462            volume_loss_tolerance: 0,
7463            bit_rot_buffer_pct: 0,
7464            index_root_fec_parity_shards: 0,
7465            ..WriterOptions::default()
7466        })
7467        .unwrap()
7468    }
7469
7470    fn payload_len_for_encrypted_data_blocks(
7471        data_block_count: u32,
7472        options: WriterOptions,
7473    ) -> usize {
7474        assert!(data_block_count > 0);
7475        if data_block_count == 1 {
7476            return 1;
7477        }
7478        let block_size = options.block_size as usize;
7479        (data_block_count as usize - 1) * block_size - options.aead_algo.tag_len() + 1
7480    }
7481
7482    fn assert_path_specific_member_group(group: &[u8]) {
7483        let mut cursor = 0usize;
7484        let mut saw_main = false;
7485        while cursor < group.len() {
7486            let header = &group[cursor..cursor + TAR_BLOCK_LEN];
7487            assert!(
7488                header.iter().any(|byte| *byte != 0),
7489                "writer emitted tar zero block inside member group"
7490            );
7491            let typeflag = header[156];
7492            assert_ne!(typeflag, b'g', "writer emitted global PAX metadata");
7493            assert!(
7494                !matches!(typeflag, b'V' | b'M' | b'N'),
7495                "writer emitted global GNU metadata"
7496            );
7497            assert!(
7498                matches!(typeflag, b'x' | b'0'),
7499                "writer emitted unexpected tar record type {typeflag:?}"
7500            );
7501            if typeflag == b'0' {
7502                saw_main = true;
7503            }
7504            let size = read_test_tar_octal(&header[124..136]);
7505            cursor += TAR_BLOCK_LEN + size + padding_to_512(size);
7506        }
7507        assert_eq!(cursor, group.len());
7508        assert!(saw_main);
7509    }
7510
7511    fn read_test_tar_octal(field: &[u8]) -> usize {
7512        let mut value = 0usize;
7513        for byte in field {
7514            match *byte {
7515                0 | b' ' => break,
7516                b'0'..=b'7' => {
7517                    value = value * 8 + usize::from(*byte - b'0');
7518                }
7519                other => panic!("malformed test tar octal byte {other:?}"),
7520            }
7521        }
7522        value
7523    }
7524
7525    fn test_file_row(idx: usize, path_hash: [u8; 8]) -> FileRow {
7526        let path = format!("file-{idx:05}.txt").into_bytes();
7527        FileRow {
7528            path_hash,
7529            path: path.clone(),
7530            member_index: idx,
7531            member: TarMember {
7532                path,
7533                tar_member_group_start: idx as u64 * 512,
7534                tar_member_group_size: 512,
7535                file_data_size: 0,
7536                mode: 0o644,
7537                mtime: 0,
7538            },
7539        }
7540    }
7541}