Skip to main content

tzap_core/
writer.rs

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