Skip to main content

tzap_core/
writer.rs

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