Skip to main content

tzap_core/
writer.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use sha2::{Digest, Sha256};
4use uuid::Uuid;
5
6use crate::compression::{compress_zstd_frame, compress_zstd_frame_with_dictionary};
7use crate::crypto::{
8    aead_encrypt, build_aad, compute_hmac, derive_nonce, HmacDomain, KdfParams, MasterKey, Subkeys,
9};
10use crate::fec::encode_parity_gf16;
11use crate::format::{
12    AeadAlgo, BlockKind, CompressionAlgo, FecAlgo, FormatError, KdfAlgo, BLOCK_RECORD_FRAMING_LEN,
13    BOOTSTRAP_SIDECAR_HEADER_LEN, CRITICAL_RECOVERY_LOCATOR_LEN, CRYPTO_EXTENSION_HEADER_LEN,
14    CRYPTO_HEADER_FIXED_LEN, CRYPTO_HEADER_HMAC_LEN, FORMAT_VERSION, MANIFEST_FOOTER_LEN,
15    READER_MAX_CMRA_PARITY_PCT, VOLUME_FORMAT_REV, VOLUME_HEADER_LEN, VOLUME_TRAILER_LEN,
16};
17use crate::metadata::{
18    hash_prefix, normalize_lookup_file_path, DirectoryHintEntry, DirectoryHintShardEntry,
19    DirectoryHintTableHeader, EnvelopeEntry, FileEntry, FrameEntry, IndexRoot, IndexRootHeader,
20    IndexShardHeader, ShardEntry, DIRECTORY_HINT_ENTRY_LEN, DIRECTORY_HINT_TABLE_LEN,
21    ENVELOPE_ENTRY_LEN, FILE_ENTRY_LEN, FRAME_ENTRY_LEN, INDEX_SHARD_HEADER_LEN,
22};
23use crate::padding::suffix_pad_for_aead;
24use crate::root_auth::{
25    archive_root, critical_metadata_digest, data_block_merkle_root, fec_layout_digest,
26    index_digest, root_auth_descriptor_digest, signer_identity_digest, ArchiveRootInputs,
27    CriticalMetadataDigestInputs, DataBlockMerkleLeaf, FecLayoutObjectRow,
28};
29use crate::wire::{
30    BlockRecord, BootstrapSidecarHeader, CriticalMetadataImage, CriticalMetadataRecoveryHeader,
31    CriticalMetadataRecoveryShard, CriticalRecoveryLocator, CryptoHeader, CryptoHeaderFixed,
32    ManifestFooter, RootAuthFooterV1, SerializedRegion, VolumeHeader, VolumeTrailer,
33};
34
35const TAR_BLOCK_LEN: usize = 512;
36const MAX_REED_SOLOMON_GF16_SHARDS: u64 = 65_535;
37const MIN_BLOCK_SIZE: u32 = 4096;
38const DEFAULT_BLOCK_SIZE: u32 = 64 * 1024;
39const DEFAULT_CHUNK_SIZE: u32 = 256 * 1024;
40const DEFAULT_ENVELOPE_TARGET_SIZE: u32 = 1024 * 1024;
41const DEFAULT_FEC_DATA_SHARDS: u16 = 224;
42const DEFAULT_FEC_PARITY_SHARDS: u16 = 1;
43const DEFAULT_INDEX_FEC_DATA_SHARDS: u16 = 16;
44const DEFAULT_INDEX_FEC_PARITY_SHARDS: u16 = 1;
45const MIN_INDEX_ROOT_FEC_DATA_SHARDS: u16 = 16;
46const DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS: u16 = MIN_INDEX_ROOT_FEC_DATA_SHARDS;
47const DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS: u16 = 1;
48const DEFAULT_STRIPE_WIDTH: u32 = 8;
49const DEFAULT_VOLUME_LOSS_TOLERANCE: u8 = 1;
50const DEFAULT_BIT_ROT_BUFFER_PCT: u8 = 5;
51const DEFAULT_FILES_PER_INDEX_SHARD: usize = 10_000;
52const DIRECTORY_HINT_REQUIRED_FILE_COUNT: usize = 100_000;
53const MAX_FILES_PER_INDEX_SHARD: usize = 1_000_000;
54const MAX_HASH_PREFIX_RUN_FILES: usize = 50_000;
55const DEFAULT_DIRECTORY_HINT_ENTRIES_PER_SHARD: usize = 10_000;
56const CMRA_SHARD_SIZE: usize = 512;
57
58fn should_emit_directory_hints(file_count: usize) -> bool {
59    file_count > DIRECTORY_HINT_REQUIRED_FILE_COUNT
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct WriterOptions {
64    pub block_size: u32,
65    pub chunk_size: u32,
66    pub envelope_target_size: u32,
67    pub stripe_width: u32,
68    pub volume_loss_tolerance: u8,
69    pub bit_rot_buffer_pct: u8,
70    pub zstd_level: i32,
71    pub aead_algo: AeadAlgo,
72    pub fec_data_shards: u16,
73    pub fec_parity_shards: u16,
74    pub index_fec_data_shards: u16,
75    pub index_fec_parity_shards: u16,
76    pub index_root_fec_data_shards: u16,
77    pub index_root_fec_parity_shards: u16,
78    pub max_path_length: u32,
79    pub target_volume_size: Option<u64>,
80    pub archive_uuid: Option<[u8; 16]>,
81    pub session_id: Option<[u8; 16]>,
82    pub closed_at_ns: i64,
83}
84
85impl Default for WriterOptions {
86    fn default() -> Self {
87        Self {
88            block_size: DEFAULT_BLOCK_SIZE,
89            chunk_size: DEFAULT_CHUNK_SIZE,
90            envelope_target_size: DEFAULT_ENVELOPE_TARGET_SIZE,
91            stripe_width: DEFAULT_STRIPE_WIDTH,
92            volume_loss_tolerance: DEFAULT_VOLUME_LOSS_TOLERANCE,
93            bit_rot_buffer_pct: DEFAULT_BIT_ROT_BUFFER_PCT,
94            zstd_level: 3,
95            aead_algo: AeadAlgo::AesGcmSiv256,
96            fec_data_shards: DEFAULT_FEC_DATA_SHARDS,
97            fec_parity_shards: DEFAULT_FEC_PARITY_SHARDS,
98            index_fec_data_shards: DEFAULT_INDEX_FEC_DATA_SHARDS,
99            index_fec_parity_shards: DEFAULT_INDEX_FEC_PARITY_SHARDS,
100            index_root_fec_data_shards: DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS,
101            index_root_fec_parity_shards: DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS,
102            max_path_length: 4096,
103            target_volume_size: None,
104            archive_uuid: None,
105            session_id: None,
106            closed_at_ns: 0,
107        }
108    }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct RootAuthWriterConfig<'a> {
113    pub authenticator_id: u16,
114    pub signer_identity_type: u16,
115    pub signer_identity: &'a [u8],
116    pub authenticator_value_length: u32,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct RootAuthSigningRequest {
121    pub archive_uuid: [u8; 16],
122    pub session_id: [u8; 16],
123    pub archive_root: [u8; 32],
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct RegularFile<'a> {
128    pub path: &'a str,
129    pub contents: &'a [u8],
130    pub mode: u32,
131    pub mtime: u64,
132}
133
134impl<'a> RegularFile<'a> {
135    pub fn new(path: &'a str, contents: &'a [u8]) -> Self {
136        Self {
137            path,
138            contents,
139            mode: 0o644,
140            mtime: 0,
141        }
142    }
143}
144
145/// Completed archive artifacts produced by the current in-memory writer API.
146///
147/// The public writer builds all volume bytes before returning this value. It is
148/// an in-memory archive artifact builder, not a sink-based streaming writer.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct WrittenArchive {
151    pub bytes: Vec<u8>,
152    pub volumes: Vec<Vec<u8>>,
153    pub bootstrap_sidecar: Vec<u8>,
154    pub archive_uuid: [u8; 16],
155    pub session_id: [u8; 16],
156}
157
158#[derive(Debug, Clone)]
159struct TarMember {
160    path: Vec<u8>,
161    tar_member_group_start: u64,
162    tar_member_group_size: u64,
163    file_data_size: u64,
164}
165
166#[derive(Debug, Clone)]
167struct PayloadFrame {
168    frame_index: u64,
169    envelope_index: u64,
170    member_index: usize,
171    offset_in_envelope: u32,
172    compressed_size: u32,
173    decompressed_size: u32,
174    flags: u32,
175    tar_stream_offset: u64,
176}
177
178#[derive(Debug, Clone)]
179struct FileRow {
180    path_hash: [u8; 8],
181    path: Vec<u8>,
182    member_index: usize,
183    member: TarMember,
184}
185
186#[derive(Debug, Clone)]
187struct PlannedIndexShard {
188    shard_index: u64,
189    plaintext: Vec<u8>,
190    file_count: u32,
191    first_path_hash: [u8; 8],
192    last_path_hash: [u8; 8],
193}
194
195#[derive(Debug, Clone)]
196struct PlannedDirectoryHintShard {
197    hint_shard_index: u64,
198    plaintext: Vec<u8>,
199    entry_count: u64,
200    first_dir_hash: [u8; 8],
201    last_dir_hash: [u8; 8],
202}
203
204#[derive(Debug, Clone)]
205struct PayloadEnvelope {
206    envelope_index: u64,
207    plaintext: Vec<u8>,
208}
209
210#[derive(Debug, Clone)]
211struct PayloadObject {
212    envelope_index: u64,
213    plaintext_size: u32,
214    object: EncryptedObject,
215}
216
217#[derive(Debug, Clone)]
218struct EncryptedObject {
219    first_block_index: u64,
220    data_block_count: u32,
221    parity_block_count: u32,
222    encrypted_size: u32,
223    records: Vec<BlockRecord>,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227struct ObjectExtent {
228    first_block_index: u64,
229    data_block_count: u32,
230    parity_block_count: u32,
231    encrypted_size: u32,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235struct PlannedEncryptedObject {
236    data_block_count: u32,
237    parity_block_count: u32,
238    encrypted_size: u32,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242enum MetadataObjectKind {
243    IndexRoot,
244    Dictionary,
245}
246
247impl MetadataObjectKind {
248    fn too_large_error(self) -> FormatError {
249        match self {
250            Self::IndexRoot => FormatError::WriterUnsupported("IndexRoot too large"),
251            Self::Dictionary => FormatError::WriterUnsupported("dictionary object too large"),
252        }
253    }
254}
255
256impl ObjectExtent {
257    fn new(first_block_index: u64, plan: PlannedEncryptedObject) -> Result<Self, FormatError> {
258        Ok(Self {
259            first_block_index,
260            data_block_count: plan.data_block_count,
261            parity_block_count: plan.parity_block_count,
262            encrypted_size: plan.encrypted_size,
263        })
264    }
265
266    fn next_block_index(self) -> Result<u64, FormatError> {
267        checked_u64_add(
268            self.first_block_index,
269            self.data_block_count as u64 + self.parity_block_count as u64,
270            "next_block_index",
271        )
272    }
273}
274
275#[derive(Debug, Clone)]
276struct PlannedDirectoryHintObject {
277    hint_shard_index: u64,
278    compressed: Vec<u8>,
279    extent: ObjectExtent,
280}
281
282pub fn write_archive(
283    files: &[RegularFile<'_>],
284    master_key: &MasterKey,
285    options: WriterOptions,
286) -> Result<WrittenArchive, FormatError> {
287    write_archive_inner(
288        files,
289        master_key,
290        options,
291        None,
292        &KdfParams::Raw,
293        None,
294        None,
295    )
296}
297
298pub fn write_archive_with_kdf(
299    files: &[RegularFile<'_>],
300    master_key: &MasterKey,
301    options: WriterOptions,
302    kdf_params: &KdfParams,
303) -> Result<WrittenArchive, FormatError> {
304    write_archive_inner(files, master_key, options, None, kdf_params, None, None)
305}
306
307pub fn write_archive_with_root_auth<F>(
308    files: &[RegularFile<'_>],
309    master_key: &MasterKey,
310    options: WriterOptions,
311    root_auth: RootAuthWriterConfig<'_>,
312    mut authenticator: F,
313) -> Result<WrittenArchive, FormatError>
314where
315    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
316{
317    write_archive_inner(
318        files,
319        master_key,
320        options,
321        None,
322        &KdfParams::Raw,
323        Some(root_auth),
324        Some(&mut authenticator),
325    )
326}
327
328pub fn write_archive_with_root_auth_and_kdf<F>(
329    files: &[RegularFile<'_>],
330    master_key: &MasterKey,
331    options: WriterOptions,
332    kdf_params: &KdfParams,
333    root_auth: RootAuthWriterConfig<'_>,
334    mut authenticator: F,
335) -> Result<WrittenArchive, FormatError>
336where
337    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
338{
339    write_archive_inner(
340        files,
341        master_key,
342        options,
343        None,
344        kdf_params,
345        Some(root_auth),
346        Some(&mut authenticator),
347    )
348}
349
350pub fn write_archive_with_dictionary_and_root_auth<F>(
351    files: &[RegularFile<'_>],
352    master_key: &MasterKey,
353    options: WriterOptions,
354    dictionary: &[u8],
355    root_auth: RootAuthWriterConfig<'_>,
356    mut authenticator: F,
357) -> Result<WrittenArchive, FormatError>
358where
359    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
360{
361    write_archive_inner(
362        files,
363        master_key,
364        options,
365        Some(dictionary),
366        &KdfParams::Raw,
367        Some(root_auth),
368        Some(&mut authenticator),
369    )
370}
371
372pub fn write_archive_with_dictionary_kdf_and_root_auth<F>(
373    files: &[RegularFile<'_>],
374    master_key: &MasterKey,
375    options: WriterOptions,
376    dictionary: &[u8],
377    kdf_params: &KdfParams,
378    root_auth: RootAuthWriterConfig<'_>,
379    mut authenticator: F,
380) -> Result<WrittenArchive, FormatError>
381where
382    F: FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
383{
384    write_archive_inner(
385        files,
386        master_key,
387        options,
388        Some(dictionary),
389        kdf_params,
390        Some(root_auth),
391        Some(&mut authenticator),
392    )
393}
394
395pub fn write_archive_with_dictionary(
396    files: &[RegularFile<'_>],
397    master_key: &MasterKey,
398    options: WriterOptions,
399    dictionary: &[u8],
400) -> Result<WrittenArchive, FormatError> {
401    if dictionary.is_empty() {
402        return Err(FormatError::WriterUnsupported(
403            "dictionary archives require a non-empty dictionary",
404        ));
405    }
406    if files.is_empty() {
407        return Err(FormatError::WriterUnsupported(
408            "dictionary archives require at least one file",
409        ));
410    }
411    if dictionary.len() > u32::MAX as usize {
412        return Err(FormatError::WriterUnsupported(
413            "dictionary decompressed size exceeds u32",
414        ));
415    }
416    write_archive_inner(
417        files,
418        master_key,
419        options,
420        Some(dictionary),
421        &KdfParams::Raw,
422        None,
423        None,
424    )
425}
426
427pub fn write_archive_with_dictionary_and_kdf(
428    files: &[RegularFile<'_>],
429    master_key: &MasterKey,
430    options: WriterOptions,
431    dictionary: &[u8],
432    kdf_params: &KdfParams,
433) -> Result<WrittenArchive, FormatError> {
434    if dictionary.is_empty() {
435        return Err(FormatError::WriterUnsupported(
436            "dictionary archives require a non-empty dictionary",
437        ));
438    }
439    if files.is_empty() {
440        return Err(FormatError::WriterUnsupported(
441            "dictionary archives require at least one file",
442        ));
443    }
444    if dictionary.len() > u32::MAX as usize {
445        return Err(FormatError::WriterUnsupported(
446            "dictionary decompressed size exceeds u32",
447        ));
448    }
449    write_archive_inner(
450        files,
451        master_key,
452        options,
453        Some(dictionary),
454        kdf_params,
455        None,
456        None,
457    )
458}
459
460fn write_archive_inner(
461    files: &[RegularFile<'_>],
462    master_key: &MasterKey,
463    options: WriterOptions,
464    dictionary: Option<&[u8]>,
465    kdf_params: &KdfParams,
466    root_auth: Option<RootAuthWriterConfig<'_>>,
467    mut authenticator: Option<
468        &mut dyn FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
469    >,
470) -> Result<WrittenArchive, FormatError> {
471    let mut requested_options = options;
472    if requested_options.target_volume_size.is_some() {
473        requested_options.stripe_width = requested_options
474            .stripe_width
475            .max(requested_options.volume_loss_tolerance as u32 + 1);
476    }
477    let archive_uuid = requested_options
478        .archive_uuid
479        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
480    let session_id = requested_options
481        .session_id
482        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
483    loop {
484        let planned_options = plan_writer_options(requested_options)?;
485        let archive = match authenticator.as_mut() {
486            Some(signer) => write_archive_once(
487                files,
488                master_key,
489                planned_options,
490                dictionary,
491                kdf_params,
492                archive_uuid,
493                session_id,
494                root_auth,
495                Some(&mut **signer),
496            )?,
497            None => write_archive_once(
498                files,
499                master_key,
500                planned_options,
501                dictionary,
502                kdf_params,
503                archive_uuid,
504                session_id,
505                root_auth,
506                None,
507            )?,
508        };
509
510        let Some(target_volume_size) = planned_options.target_volume_size else {
511            return Ok(archive);
512        };
513        let required_stripe_width =
514            required_stripe_width_for_target(&archive, planned_options, target_volume_size)?;
515        if required_stripe_width <= planned_options.stripe_width {
516            return Ok(archive);
517        }
518        requested_options.stripe_width = required_stripe_width;
519    }
520}
521
522fn write_archive_once(
523    files: &[RegularFile<'_>],
524    master_key: &MasterKey,
525    mut options: WriterOptions,
526    dictionary: Option<&[u8]>,
527    kdf_params: &KdfParams,
528    archive_uuid: [u8; 16],
529    session_id: [u8; 16],
530    root_auth: Option<RootAuthWriterConfig<'_>>,
531    mut authenticator: Option<
532        &mut dyn FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
533    >,
534) -> Result<WrittenArchive, FormatError> {
535    let subkeys = Subkeys::derive(master_key, &archive_uuid, &session_id)?;
536    let mut next_block_index = 0u64;
537    let mut block_records = Vec::new();
538    let (tar_stream, tar_members) = build_tar_stream(files, options.max_path_length)?;
539    let tar_total_size = tar_stream.len() as u64;
540    let content_sha256 = sha256_bytes(&tar_stream);
541
542    let (payload_objects, frames, payload_block_count) = if tar_stream.is_empty() {
543        (Vec::new(), Vec::new(), 0u64)
544    } else {
545        let (payload_envelopes, frames) =
546            build_payload_envelopes(&tar_stream, &tar_members, options, dictionary)?;
547        let mut objects = Vec::with_capacity(payload_envelopes.len());
548        let mut payload_block_count = 0u64;
549        for envelope in payload_envelopes {
550            let plaintext_size = u32_len(envelope.plaintext.len(), "EnvelopeEntry.plaintext_size")?;
551            let object = encrypt_object(
552                &envelope.plaintext,
553                &subkeys.enc_key,
554                &subkeys.nonce_seed,
555                b"envelope",
556                envelope.envelope_index,
557                BlockKind::PayloadData,
558                BlockKind::PayloadParity,
559                options.fec_data_shards,
560                options.fec_parity_shards,
561                &mut next_block_index,
562                options,
563                &archive_uuid,
564                &session_id,
565            )?;
566            payload_block_count = checked_u64_add(
567                payload_block_count,
568                object.data_block_count as u64,
569                "payload",
570            )?;
571            block_records.extend(object.records.clone());
572            objects.push(PayloadObject {
573                envelope_index: envelope.envelope_index,
574                plaintext_size,
575                object,
576            });
577        }
578        (objects, frames, payload_block_count)
579    };
580
581    let (shard_file_rows, planned_index_shards) = if tar_members.is_empty() {
582        (Vec::new(), Vec::new())
583    } else {
584        let rows = sorted_file_rows(&tar_members);
585        let shard_file_rows = partition_file_rows(rows)?;
586        let planned_index_shards =
587            build_index_shard_plaintexts(&shard_file_rows, &frames, &payload_objects, options)?;
588        (shard_file_rows, planned_index_shards)
589    };
590
591    let mut shard_entries = Vec::with_capacity(planned_index_shards.len());
592    for planned in planned_index_shards {
593        let compressed = compress_zstd_frame(&planned.plaintext, options.zstd_level)?;
594        let object = encrypt_object(
595            &compressed,
596            &subkeys.index_shard_key,
597            &subkeys.index_nonce_seed,
598            b"idxshard",
599            planned.shard_index,
600            BlockKind::IndexShardData,
601            BlockKind::IndexShardParity,
602            options.index_fec_data_shards,
603            options.index_fec_parity_shards,
604            &mut next_block_index,
605            options,
606            &archive_uuid,
607            &session_id,
608        )?;
609        shard_entries.push(ShardEntry {
610            shard_index: planned.shard_index,
611            first_block_index: object.first_block_index,
612            data_block_count: object.data_block_count,
613            parity_block_count: object.parity_block_count,
614            encrypted_size: object.encrypted_size,
615            decompressed_size: u32_len(planned.plaintext.len(), "IndexShard")?,
616            file_count: planned.file_count,
617            first_path_hash: planned.first_path_hash,
618            last_path_hash: planned.last_path_hash,
619        });
620        block_records.extend(object.records.clone());
621    }
622    let frame_count = frames.len() as u64;
623    let envelope_count = payload_objects.len() as u64;
624
625    let compressed_dictionary = dictionary
626        .map(|dictionary| compress_zstd_frame(dictionary, options.zstd_level))
627        .transpose()?;
628    let dictionary_decompressed_size = dictionary
629        .map(|dictionary| u32_len(dictionary.len(), "dictionary"))
630        .transpose()?;
631    let dictionary_plan = compressed_dictionary
632        .as_ref()
633        .map(|compressed| {
634            plan_metadata_object_without_class(
635                compressed.len(),
636                options,
637                MetadataObjectKind::Dictionary,
638            )
639        })
640        .transpose()?;
641    let dictionary_extent = dictionary_plan
642        .map(|plan| ObjectExtent::new(next_block_index, plan))
643        .transpose()?;
644    let next_after_dictionary = if let Some(extent) = dictionary_extent {
645        extent.next_block_index()?
646    } else {
647        next_block_index
648    };
649
650    let planned_directory_hint_shards = if should_emit_directory_hints(tar_members.len()) {
651        build_directory_hint_plaintexts(&shard_file_rows, options)?
652    } else {
653        Vec::new()
654    };
655    let mut directory_hint_entries = Vec::with_capacity(planned_directory_hint_shards.len());
656    let mut planned_directory_hint_objects =
657        Vec::with_capacity(planned_directory_hint_shards.len());
658    let mut planned_next_block_index = next_after_dictionary;
659    for planned in planned_directory_hint_shards {
660        let compressed = compress_zstd_frame(&planned.plaintext, options.zstd_level)?;
661        let object_plan = plan_encrypted_object(
662            compressed.len(),
663            options.index_fec_data_shards,
664            options.index_fec_parity_shards,
665            options,
666        )?;
667        let extent = ObjectExtent::new(planned_next_block_index, object_plan)?;
668        planned_next_block_index = extent.next_block_index()?;
669        directory_hint_entries.push(DirectoryHintShardEntry {
670            hint_shard_index: planned.hint_shard_index,
671            first_dir_hash: planned.first_dir_hash,
672            last_dir_hash: planned.last_dir_hash,
673            first_block_index: extent.first_block_index,
674            data_block_count: extent.data_block_count,
675            parity_block_count: extent.parity_block_count,
676            encrypted_size: extent.encrypted_size,
677            decompressed_size: u32_len(planned.plaintext.len(), "DirectoryHintTable")?,
678            entry_count: planned.entry_count,
679        });
680        planned_directory_hint_objects.push(PlannedDirectoryHintObject {
681            hint_shard_index: planned.hint_shard_index,
682            compressed,
683            extent,
684        });
685    }
686
687    let index_root_plaintext = build_index_root_plaintext(
688        &shard_entries,
689        frame_count,
690        envelope_count,
691        tar_members.len() as u64,
692        payload_block_count,
693        tar_total_size,
694        content_sha256,
695        &directory_hint_entries,
696        dictionary_extent
697            .zip(dictionary_decompressed_size)
698            .map(|(extent, decompressed_size)| (extent, decompressed_size)),
699    );
700    let compressed_index_root = compress_zstd_frame(&index_root_plaintext, options.zstd_level)?;
701    let metadata_class = plan_index_root_metadata_class(
702        options,
703        compressed_index_root.len(),
704        compressed_dictionary.as_ref().map(Vec::len),
705    )?;
706    options = metadata_class.options;
707    let crypto_header = build_crypto_header(
708        options,
709        dictionary.is_some(),
710        &subkeys,
711        &archive_uuid,
712        &session_id,
713        kdf_params,
714    )?;
715
716    let actual_dictionary_extent =
717        if let (Some(compressed_dictionary), Some(expected_extent), Some(decompressed_size)) = (
718            compressed_dictionary.as_ref(),
719            dictionary_extent,
720            dictionary_decompressed_size,
721        ) {
722            let object = encrypt_object(
723                compressed_dictionary,
724                &subkeys.dictionary_key,
725                &subkeys.index_nonce_seed,
726                b"dict",
727                0,
728                BlockKind::DictionaryData,
729                BlockKind::DictionaryParity,
730                options.index_root_fec_data_shards,
731                options.index_root_fec_parity_shards,
732                &mut next_block_index,
733                options,
734                &archive_uuid,
735                &session_id,
736            )
737            .map_err(|err| map_metadata_encrypt_error(err, MetadataObjectKind::Dictionary))?;
738            if let Some(dictionary_plan) = metadata_class.dictionary {
739                validate_planned_object(&object, dictionary_plan)?;
740            }
741            validate_planned_extent(&object, expected_extent)?;
742            block_records.extend(object.records.clone());
743            Some((object, decompressed_size))
744        } else {
745            None
746        };
747    for planned in planned_directory_hint_objects {
748        let object = encrypt_object(
749            &planned.compressed,
750            &subkeys.dir_hint_key,
751            &subkeys.index_nonce_seed,
752            b"dirhint",
753            planned.hint_shard_index,
754            BlockKind::DirectoryHintData,
755            BlockKind::DirectoryHintParity,
756            options.index_fec_data_shards,
757            options.index_fec_parity_shards,
758            &mut next_block_index,
759            options,
760            &archive_uuid,
761            &session_id,
762        )?;
763        validate_planned_extent(&object, planned.extent)?;
764        block_records.extend(object.records.clone());
765    }
766    let index_root_extent = encrypt_object(
767        &compressed_index_root,
768        &subkeys.index_root_key,
769        &subkeys.index_nonce_seed,
770        b"idxroot",
771        0,
772        BlockKind::IndexRootData,
773        BlockKind::IndexRootParity,
774        options.index_root_fec_data_shards,
775        options.index_root_fec_parity_shards,
776        &mut next_block_index,
777        options,
778        &archive_uuid,
779        &session_id,
780    )
781    .map_err(|err| map_metadata_encrypt_error(err, MetadataObjectKind::IndexRoot))?;
782    validate_planned_object(&index_root_extent, metadata_class.index_root)?;
783    block_records.extend(index_root_extent.records.clone());
784
785    let stripe_width = options.stripe_width as usize;
786    let mut striped_records = vec![Vec::<BlockRecord>::new(); stripe_width];
787    for record in &block_records {
788        let volume_index = (record.block_index % options.stripe_width as u64) as usize;
789        striped_records[volume_index].push(record.clone());
790    }
791
792    let volume_zero_manifest = build_manifest_footer(
793        &subkeys,
794        archive_uuid,
795        session_id,
796        0,
797        options.stripe_width,
798        &index_root_extent,
799        index_root_plaintext.len(),
800    )?;
801    let root_auth_footer = match root_auth {
802        Some(config) => {
803            let signer = authenticator
804                .as_deref_mut()
805                .ok_or(FormatError::WriterInvariant(
806                    "missing root-auth authenticator",
807                ))?;
808            Some(build_root_auth_footer(
809                config,
810                signer,
811                archive_uuid,
812                session_id,
813                options,
814                &crypto_header,
815                &volume_zero_manifest,
816                &index_root_plaintext,
817                &index_root_extent,
818                actual_dictionary_extent
819                    .as_ref()
820                    .map(|(object, decompressed_size)| (object, *decompressed_size)),
821                &shard_entries,
822                &payload_objects,
823                &directory_hint_entries,
824                &block_records,
825            )?)
826        }
827        None => None,
828    };
829
830    let mut volumes = Vec::with_capacity(stripe_width);
831    for (volume_index, records) in striped_records.iter().enumerate() {
832        let volume_index = u32::try_from(volume_index)
833            .map_err(|_| FormatError::WriterUnsupported("volume_index"))?;
834        let volume_header = VolumeHeader {
835            format_version: FORMAT_VERSION,
836            volume_format_rev: VOLUME_FORMAT_REV,
837            volume_index,
838            stripe_width: options.stripe_width,
839            archive_uuid,
840            session_id,
841            crypto_header_offset: VOLUME_HEADER_LEN as u32,
842            crypto_header_length: u32_len(crypto_header.len(), "CryptoHeader")?,
843            header_crc32c: 0,
844        };
845        let volume_header_bytes = volume_header.to_bytes();
846
847        let mut bytes = Vec::new();
848        bytes.extend_from_slice(&volume_header_bytes);
849        bytes.extend_from_slice(&crypto_header);
850        for record in records {
851            bytes.extend_from_slice(&record.to_bytes());
852        }
853
854        let manifest_footer_offset = bytes.len() as u64;
855        let manifest_footer = build_manifest_footer(
856            &subkeys,
857            archive_uuid,
858            session_id,
859            volume_index,
860            options.stripe_width,
861            &index_root_extent,
862            index_root_plaintext.len(),
863        )?;
864        bytes.extend_from_slice(&manifest_footer);
865
866        let root_auth_footer_offset = root_auth_footer.as_ref().map(|footer| {
867            let offset = bytes.len() as u64;
868            bytes.extend_from_slice(footer);
869            offset
870        });
871        let root_auth_footer_length = root_auth_footer
872            .as_ref()
873            .map(|footer| u32_len(footer.len(), "RootAuthFooterV1"))
874            .transpose()?;
875        let trailer_offset = bytes.len() as u64;
876        let trailer = build_volume_trailer(
877            &subkeys,
878            archive_uuid,
879            session_id,
880            volume_index,
881            records.len() as u64,
882            trailer_offset,
883            manifest_footer_offset,
884            options.closed_at_ns,
885            root_auth_footer_offset.zip(root_auth_footer_length),
886        );
887        bytes.extend_from_slice(&trailer);
888        let cmra_offset = bytes.len() as u64;
889        let cmra = build_v41_cmra(
890            &volume_header_bytes,
891            &crypto_header,
892            records.len() as u64,
893            manifest_footer_offset,
894            &manifest_footer,
895            root_auth_footer_offset,
896            root_auth_footer.as_deref(),
897            trailer_offset,
898            &trailer,
899            cmra_offset,
900            options,
901            archive_uuid,
902            session_id,
903            volume_index,
904        )?;
905        bytes.extend_from_slice(&cmra.bytes);
906        let locator_base = CriticalRecoveryLocator {
907            cmra_offset,
908            cmra_length: u32_len(cmra.bytes.len(), "CMRA")?,
909            volume_trailer_offset: trailer_offset,
910            body_bytes_before_cmra: cmra_offset,
911            archive_uuid_hint: archive_uuid,
912            session_id_hint: session_id,
913            volume_index_hint: volume_index,
914            locator_sequence: 1,
915            cmra_shard_size: cmra.shard_size,
916            cmra_data_shard_count: cmra.data_shard_count,
917            cmra_parity_shard_count: cmra.parity_shard_count,
918            cmra_image_length: cmra.image_length,
919            cmra_image_sha256: cmra.image_sha256,
920            locator_crc32c: 0,
921        };
922        let mirror = locator_base.to_bytes();
923        bytes.extend_from_slice(&mirror);
924        let final_locator = CriticalRecoveryLocator {
925            locator_sequence: 0,
926            ..locator_base
927        }
928        .to_bytes();
929        bytes.extend_from_slice(&final_locator);
930
931        if volume_index == 0 {
932            debug_assert_eq!(volume_zero_manifest, manifest_footer);
933        }
934        volumes.push(bytes);
935    }
936
937    let bootstrap_sidecar = if options.stripe_width == 1 {
938        build_bootstrap_sidecar(
939            &subkeys,
940            archive_uuid,
941            session_id,
942            &volume_zero_manifest,
943            &index_root_extent.records,
944            actual_dictionary_extent
945                .as_ref()
946                .map(|(object, _)| object.records.as_slice()),
947        )?
948    } else {
949        Vec::new()
950    };
951
952    Ok(WrittenArchive {
953        bytes: volumes
954            .first()
955            .cloned()
956            .ok_or(FormatError::WriterInvariant("no volumes emitted"))?,
957        volumes,
958        bootstrap_sidecar,
959        archive_uuid,
960        session_id,
961    })
962}
963
964fn required_stripe_width_for_target(
965    archive: &WrittenArchive,
966    options: WriterOptions,
967    target_volume_size: u64,
968) -> Result<u32, FormatError> {
969    let max_volume_size = archive
970        .volumes
971        .iter()
972        .map(|volume| volume.len() as u64)
973        .max()
974        .unwrap_or(0);
975    if max_volume_size <= target_volume_size {
976        return Ok(options.stripe_width);
977    }
978
979    let first_volume = archive
980        .volumes
981        .first()
982        .ok_or(FormatError::WriterInvariant("no volumes emitted"))?;
983    let block_record_len = options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
984    let current_overhead = v41_emitted_volume_overhead(first_volume, block_record_len)?;
985    if target_volume_size <= current_overhead {
986        return Err(FormatError::WriterUnsupported(
987            "volume-size is too small for per-volume metadata",
988        ));
989    }
990
991    let records_per_volume = (target_volume_size - current_overhead) / block_record_len;
992    if records_per_volume == 0 {
993        return Err(FormatError::WriterUnsupported(
994            "volume-size is too small for the configured block-size",
995        ));
996    }
997
998    let total_records = archive.volumes.iter().try_fold(0u64, |total, volume| {
999        let volume_len = volume.len() as u64;
1000        let overhead = v41_emitted_volume_overhead(volume, block_record_len)?;
1001        if volume_len < overhead {
1002            return Err(FormatError::WriterInvariant("emitted volume too short"));
1003        }
1004        let record_bytes = volume_len - overhead;
1005        total
1006            .checked_add(record_bytes / block_record_len)
1007            .ok_or(FormatError::WriterUnsupported("volume count overflow"))
1008    })?;
1009    let required = ceil_div(total_records, records_per_volume)?
1010        .max(options.volume_loss_tolerance as u64 + 1)
1011        .max(1);
1012    u32::try_from(required).map_err(|_| FormatError::WriterUnsupported("volume count"))
1013}
1014
1015fn v41_emitted_volume_overhead(volume: &[u8], block_record_len: u64) -> Result<u64, FormatError> {
1016    if volume.len() < CRITICAL_RECOVERY_LOCATOR_LEN {
1017        return Err(FormatError::WriterInvariant("emitted volume too short"));
1018    }
1019    let locator_offset = volume.len() - CRITICAL_RECOVERY_LOCATOR_LEN;
1020    let locator = CriticalRecoveryLocator::parse(&volume[locator_offset..])?;
1021    let trailer_offset = to_usize_writer(locator.volume_trailer_offset, "VolumeTrailer")?;
1022    let trailer_end = trailer_offset
1023        .checked_add(VOLUME_TRAILER_LEN)
1024        .ok_or(FormatError::WriterInvariant("VolumeTrailer overflow"))?;
1025    let trailer = VolumeTrailer::parse(volume.get(trailer_offset..trailer_end).ok_or(
1026        FormatError::WriterInvariant("truncated emitted VolumeTrailer"),
1027    )?)?;
1028    let record_bytes =
1029        trailer
1030            .block_count
1031            .checked_mul(block_record_len)
1032            .ok_or(FormatError::WriterUnsupported(
1033                "volume record byte overflow",
1034            ))?;
1035    (volume.len() as u64)
1036        .checked_sub(record_bytes)
1037        .ok_or(FormatError::WriterInvariant(
1038            "emitted volume record overflow",
1039        ))
1040}
1041
1042pub fn write_empty_archive(master_key: &MasterKey) -> Result<WrittenArchive, FormatError> {
1043    write_archive(&[], master_key, WriterOptions::default())
1044}
1045
1046fn plan_writer_options(mut options: WriterOptions) -> Result<WriterOptions, FormatError> {
1047    if options.block_size < MIN_BLOCK_SIZE || options.block_size % 2 != 0 {
1048        return Err(FormatError::WriterUnsupported(
1049            "writer requires an even block size of at least 4096",
1050        ));
1051    }
1052    if options.stripe_width == 0 {
1053        return Err(FormatError::WriterUnsupported(
1054            "stripe_width must be non-zero",
1055        ));
1056    }
1057    if options.volume_loss_tolerance as u32 >= options.stripe_width {
1058        return Err(FormatError::WriterUnsupported(
1059            "volume_loss_tolerance must be less than stripe_width",
1060        ));
1061    }
1062    if options.stripe_width == 1 && options.volume_loss_tolerance != 0 {
1063        return Err(FormatError::WriterUnsupported(
1064            "single-volume archives cannot tolerate volume loss",
1065        ));
1066    }
1067    if matches!(options.target_volume_size, Some(0)) {
1068        return Err(FormatError::WriterUnsupported(
1069            "target_volume_size must be non-zero",
1070        ));
1071    }
1072    if options.bit_rot_buffer_pct > 100 {
1073        return Err(FormatError::WriterUnsupported(
1074            "bit_rot_buffer_pct must be at most 100",
1075        ));
1076    }
1077    if options.chunk_size == 0 || options.chunk_size > options.envelope_target_size {
1078        return Err(FormatError::WriterUnsupported(
1079            "chunk_size must be non-zero and no larger than envelope_target_size",
1080        ));
1081    }
1082    if options.fec_data_shards == 0
1083        || options.index_fec_data_shards == 0
1084        || options.index_root_fec_data_shards == 0
1085    {
1086        return Err(FormatError::WriterUnsupported(
1087            "FEC data shard class maxima must be non-zero",
1088        ));
1089    }
1090    options.index_root_fec_data_shards = options
1091        .index_root_fec_data_shards
1092        .max(MIN_INDEX_ROOT_FEC_DATA_SHARDS);
1093    options.fec_parity_shards =
1094        compute_parity_u16(options.fec_data_shards as u64, options, "fec_parity_shards")?;
1095    options.index_fec_parity_shards = compute_parity_u16(
1096        options.index_fec_data_shards as u64,
1097        options,
1098        "index_fec_parity_shards",
1099    )?;
1100    options.index_root_fec_parity_shards = compute_parity_u16(
1101        options.index_root_fec_data_shards as u64,
1102        options,
1103        "index_root_fec_parity_shards",
1104    )?;
1105    Ok(options)
1106}
1107
1108fn build_crypto_header(
1109    options: WriterOptions,
1110    has_dictionary: bool,
1111    subkeys: &Subkeys,
1112    archive_uuid: &[u8; 16],
1113    session_id: &[u8; 16],
1114    kdf_params: &KdfParams,
1115) -> Result<Vec<u8>, FormatError> {
1116    let kdf_payload = serialize_kdf_params(kdf_params)?;
1117    let length = CRYPTO_HEADER_FIXED_LEN
1118        .checked_add(kdf_payload.len())
1119        .and_then(|value| value.checked_add(CRYPTO_EXTENSION_HEADER_LEN))
1120        .and_then(|value| value.checked_add(CRYPTO_HEADER_HMAC_LEN))
1121        .ok_or(FormatError::WriterUnsupported(
1122            "CryptoHeader length overflow",
1123        ))?;
1124    let kdf_algo = match kdf_params {
1125        KdfParams::Raw => KdfAlgo::Raw,
1126        KdfParams::Argon2id { .. } => KdfAlgo::Argon2id,
1127    };
1128    let fixed = CryptoHeaderFixed {
1129        length: length as u32,
1130        compression_algo: CompressionAlgo::ZstdFramed,
1131        aead_algo: options.aead_algo,
1132        fec_algo: FecAlgo::ReedSolomonGF16,
1133        kdf_algo,
1134        chunk_size: options.chunk_size,
1135        envelope_target_size: options.envelope_target_size,
1136        block_size: options.block_size,
1137        fec_data_shards: options.fec_data_shards,
1138        fec_parity_shards: options.fec_parity_shards,
1139        index_fec_data_shards: options.index_fec_data_shards,
1140        index_fec_parity_shards: options.index_fec_parity_shards,
1141        index_root_fec_data_shards: options.index_root_fec_data_shards,
1142        index_root_fec_parity_shards: options.index_root_fec_parity_shards,
1143        stripe_width: options.stripe_width,
1144        volume_loss_tolerance: options.volume_loss_tolerance,
1145        bit_rot_buffer_pct: options.bit_rot_buffer_pct,
1146        has_dictionary: if has_dictionary { 1 } else { 0 },
1147        max_path_length: options.max_path_length,
1148        expected_volume_size: options.target_volume_size.unwrap_or(0),
1149    };
1150
1151    let mut bytes = fixed.to_bytes().to_vec();
1152    bytes.extend_from_slice(&kdf_payload);
1153    bytes.extend_from_slice(&0u16.to_le_bytes());
1154    bytes.extend_from_slice(&0u32.to_le_bytes());
1155    let hmac = compute_hmac(
1156        HmacDomain::CryptoHeader,
1157        &subkeys.mac_key,
1158        archive_uuid,
1159        session_id,
1160        &bytes,
1161    );
1162    bytes.extend_from_slice(&hmac);
1163    Ok(bytes)
1164}
1165
1166fn serialize_kdf_params(params: &KdfParams) -> Result<Vec<u8>, FormatError> {
1167    let mut bytes = Vec::new();
1168    match params {
1169        KdfParams::Raw => {
1170            bytes.extend_from_slice(&(KdfAlgo::Raw as u16).to_le_bytes());
1171        }
1172        KdfParams::Argon2id {
1173            t_cost,
1174            m_cost_kib,
1175            parallelism,
1176            salt,
1177        } => {
1178            if *t_cost == 0 {
1179                return Err(FormatError::InvalidKdfParams("t_cost must be non-zero"));
1180            }
1181            if *parallelism == 0 {
1182                return Err(FormatError::InvalidKdfParams(
1183                    "parallelism must be non-zero",
1184                ));
1185            }
1186            let min_memory = parallelism
1187                .checked_mul(8)
1188                .ok_or(FormatError::InvalidKdfParams(
1189                    "m_cost_kib requirement overflow",
1190                ))?;
1191            if *m_cost_kib < min_memory {
1192                return Err(FormatError::InvalidKdfParams(
1193                    "m_cost_kib must be at least 8 * parallelism",
1194                ));
1195            }
1196            if !(8..=64).contains(&salt.len()) {
1197                return Err(FormatError::InvalidKdfParams(
1198                    "argon2id salt length must be 8..64",
1199                ));
1200            }
1201            let salt_len = u16::try_from(salt.len())
1202                .map_err(|_| FormatError::InvalidKdfParams("argon2id salt too long"))?;
1203            bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
1204            bytes.extend_from_slice(&t_cost.to_le_bytes());
1205            bytes.extend_from_slice(&m_cost_kib.to_le_bytes());
1206            bytes.extend_from_slice(&parallelism.to_le_bytes());
1207            bytes.extend_from_slice(&salt_len.to_le_bytes());
1208            bytes.extend_from_slice(salt);
1209        }
1210    }
1211    Ok(bytes)
1212}
1213
1214fn build_tar_stream(
1215    files: &[RegularFile<'_>],
1216    max_path_length: u32,
1217) -> Result<(Vec<u8>, Vec<TarMember>), FormatError> {
1218    let mut stream = Vec::new();
1219    let mut members = Vec::with_capacity(files.len());
1220    for file in files {
1221        let path = normalize_lookup_file_path(file.path, max_path_length)?;
1222        let start = stream.len() as u64;
1223        let member_group =
1224            build_regular_file_member_group(&path, file.contents, file.mode, file.mtime)?;
1225        stream.extend_from_slice(&member_group);
1226        members.push(TarMember {
1227            path,
1228            tar_member_group_start: start,
1229            tar_member_group_size: member_group.len() as u64,
1230            file_data_size: file.contents.len() as u64,
1231        });
1232    }
1233    Ok((stream, members))
1234}
1235
1236fn build_payload_envelopes(
1237    tar_stream: &[u8],
1238    members: &[TarMember],
1239    options: WriterOptions,
1240    dictionary: Option<&[u8]>,
1241) -> Result<(Vec<PayloadEnvelope>, Vec<PayloadFrame>), FormatError> {
1242    let chunk_size = options.chunk_size as usize;
1243    if chunk_size == 0 {
1244        return Err(FormatError::WriterUnsupported(
1245            "chunk_size must be non-zero and no larger than envelope_target_size",
1246        ));
1247    }
1248    let envelope_target_size = options.envelope_target_size as usize;
1249    let mut envelopes = Vec::new();
1250    let mut current = PayloadEnvelope {
1251        envelope_index: 0,
1252        plaintext: Vec::new(),
1253    };
1254    let mut frames = Vec::new();
1255    let mut next_frame_index = 0u64;
1256
1257    for (member_index, member) in members.iter().enumerate() {
1258        let start = member.tar_member_group_start as usize;
1259        let end = checked_usize_add(start, member.tar_member_group_size as usize, "tar member")?;
1260        let member_bytes = tar_stream
1261            .get(start..end)
1262            .ok_or(FormatError::WriterInvariant(
1263                "tar member range is out of bounds",
1264            ))?;
1265        let mut member_offset = 0usize;
1266        while member_offset < member_bytes.len() {
1267            let mut chunk_len = (member_bytes.len() - member_offset).min(chunk_size);
1268            let frame = loop {
1269                let end = checked_usize_add(member_offset, chunk_len, "payload chunk")?;
1270                let chunk = &member_bytes[member_offset..end];
1271                let frame = if let Some(dictionary) = dictionary {
1272                    compress_zstd_frame_with_dictionary(chunk, options.zstd_level, dictionary)?
1273                } else {
1274                    compress_zstd_frame(chunk, options.zstd_level)?
1275                };
1276                if payload_object_can_fit(frame.len(), options)? {
1277                    break frame;
1278                }
1279                if chunk_len == 1 {
1280                    return Err(FormatError::WriterUnsupported(
1281                        "single-byte payload frame exceeds envelope object limits",
1282                    ));
1283                }
1284                chunk_len = (chunk_len / 2).max(1);
1285            };
1286            let next_len = checked_usize_add(current.plaintext.len(), frame.len(), "payload")?;
1287            if !current.plaintext.is_empty()
1288                && (next_len > envelope_target_size || !payload_object_can_fit(next_len, options)?)
1289            {
1290                envelopes.push(current);
1291                current = PayloadEnvelope {
1292                    envelope_index: envelopes.len() as u64,
1293                    plaintext: Vec::new(),
1294                };
1295            }
1296
1297            if current.plaintext.is_empty() && !payload_object_can_fit(frame.len(), options)? {
1298                return Err(FormatError::WriterUnsupported(
1299                    "payload frame exceeds envelope object limits",
1300                ));
1301            }
1302            let offset = u32_len(current.plaintext.len(), "FrameEntry.offset_in_envelope")?;
1303            current.plaintext.extend_from_slice(&frame);
1304            let is_first_member_frame = member_offset == 0;
1305            let is_last_member_frame =
1306                checked_usize_add(member_offset, chunk_len, "payload chunk")? == member_bytes.len();
1307            let mut flags = 0u32;
1308            if is_first_member_frame {
1309                flags |= 0x0000_0001;
1310            }
1311            if is_last_member_frame {
1312                flags |= 0x0000_0002;
1313            }
1314            frames.push(PayloadFrame {
1315                frame_index: next_frame_index,
1316                envelope_index: current.envelope_index,
1317                member_index,
1318                offset_in_envelope: offset,
1319                compressed_size: u32_len(frame.len(), "FrameEntry.compressed_size")?,
1320                decompressed_size: u32_len(chunk_len, "FrameEntry.decompressed_size")?,
1321                flags,
1322                tar_stream_offset: checked_u64_add(
1323                    member.tar_member_group_start,
1324                    u64::try_from(member_offset)
1325                        .map_err(|_| FormatError::WriterUnsupported("chunk offset"))?,
1326                    "PayloadFrame.tar_stream_offset",
1327                )?,
1328            });
1329            next_frame_index = checked_u64_add(next_frame_index, 1, "PayloadFrame.frame_index")?;
1330            member_offset = checked_usize_add(member_offset, chunk_len, "payload chunk")?;
1331        }
1332    }
1333    if !current.plaintext.is_empty() {
1334        envelopes.push(current);
1335    }
1336    Ok((envelopes, frames))
1337}
1338
1339fn sorted_file_rows(members: &[TarMember]) -> Vec<FileRow> {
1340    let mut rows = members
1341        .iter()
1342        .enumerate()
1343        .map(|(member_index, member)| FileRow {
1344            path_hash: hash_prefix(&member.path),
1345            path: member.path.clone(),
1346            member_index,
1347            member: member.clone(),
1348        })
1349        .collect::<Vec<_>>();
1350    rows.sort_by(|left, right| {
1351        (
1352            left.path_hash,
1353            left.path.as_slice(),
1354            left.member.tar_member_group_start,
1355        )
1356            .cmp(&(
1357                right.path_hash,
1358                right.path.as_slice(),
1359                right.member.tar_member_group_start,
1360            ))
1361    });
1362    rows
1363}
1364
1365fn partition_file_rows(rows: Vec<FileRow>) -> Result<Vec<Vec<FileRow>>, FormatError> {
1366    let mut shards = Vec::new();
1367    let mut start = 0usize;
1368    while start < rows.len() {
1369        let mut end = (start + DEFAULT_FILES_PER_INDEX_SHARD).min(rows.len());
1370        if end < rows.len() && rows[end - 1].path_hash == rows[end].path_hash {
1371            let boundary_hash = rows[end].path_hash;
1372            let mut run_start_in_shard = end - 1;
1373            while run_start_in_shard > start
1374                && rows[run_start_in_shard - 1].path_hash == boundary_hash
1375            {
1376                run_start_in_shard -= 1;
1377            }
1378            let mut full_run_start = run_start_in_shard;
1379            while full_run_start > 0 && rows[full_run_start - 1].path_hash == boundary_hash {
1380                full_run_start -= 1;
1381            }
1382            let mut full_run_end = end + 1;
1383            while full_run_end < rows.len() && rows[full_run_end].path_hash == boundary_hash {
1384                full_run_end += 1;
1385            }
1386            let full_run_len = full_run_end - full_run_start;
1387            end = if full_run_len <= MAX_HASH_PREFIX_RUN_FILES {
1388                full_run_end
1389            } else {
1390                (run_start_in_shard + MAX_HASH_PREFIX_RUN_FILES).min(full_run_end)
1391            };
1392        }
1393        if end - start > MAX_FILES_PER_INDEX_SHARD {
1394            return Err(FormatError::WriterUnsupported(
1395                "hash-prefix collision run exceeds max_files_per_index_shard",
1396            ));
1397        }
1398        shards.push(rows[start..end].to_vec());
1399        start = end;
1400    }
1401    Ok(shards)
1402}
1403
1404fn build_index_shard_plaintexts(
1405    shard_rows: &[Vec<FileRow>],
1406    frames: &[PayloadFrame],
1407    payloads: &[PayloadObject],
1408    options: WriterOptions,
1409) -> Result<Vec<PlannedIndexShard>, FormatError> {
1410    let mut planned = Vec::new();
1411    for rows in shard_rows {
1412        append_index_shards_for_rows(&mut planned, rows.clone(), frames, payloads, options)?;
1413    }
1414    Ok(planned)
1415}
1416
1417fn append_index_shards_for_rows(
1418    planned: &mut Vec<PlannedIndexShard>,
1419    rows: Vec<FileRow>,
1420    frames: &[PayloadFrame],
1421    payloads: &[PayloadObject],
1422    options: WriterOptions,
1423) -> Result<(), FormatError> {
1424    let shard_index =
1425        u64::try_from(planned.len()).map_err(|_| FormatError::WriterUnsupported("shard_index"))?;
1426    let candidate = build_index_shard_plaintext(shard_index, &rows, frames, payloads, options)?;
1427    let compressed = compress_zstd_frame(&candidate.plaintext, options.zstd_level)?;
1428    if index_object_can_fit(compressed.len(), options)? {
1429        planned.push(candidate);
1430        return Ok(());
1431    }
1432    if rows.len() == 1 {
1433        return Err(FormatError::WriterUnsupported(
1434            "single-file IndexShard exceeds index object limits",
1435        ));
1436    }
1437    let split_at = split_sorted_file_rows_for_object_limit(&rows);
1438    append_index_shards_for_rows(
1439        planned,
1440        rows[..split_at].to_vec(),
1441        frames,
1442        payloads,
1443        options,
1444    )?;
1445    append_index_shards_for_rows(
1446        planned,
1447        rows[split_at..].to_vec(),
1448        frames,
1449        payloads,
1450        options,
1451    )
1452}
1453
1454fn split_sorted_file_rows_for_object_limit(rows: &[FileRow]) -> usize {
1455    let midpoint = rows.len() / 2;
1456    if rows[midpoint - 1].path_hash != rows[midpoint].path_hash {
1457        return midpoint;
1458    }
1459
1460    let boundary_hash = rows[midpoint].path_hash;
1461    let mut left = midpoint;
1462    while left > 0 && rows[left - 1].path_hash == boundary_hash {
1463        left -= 1;
1464    }
1465    let mut right = midpoint;
1466    while right < rows.len() && rows[right].path_hash == boundary_hash {
1467        right += 1;
1468    }
1469
1470    match (left > 0, right < rows.len()) {
1471        (true, true) if midpoint - left <= right - midpoint => left,
1472        (true, true) => right,
1473        (true, false) => left,
1474        (false, true) => right,
1475        (false, false) => midpoint,
1476    }
1477}
1478
1479fn build_index_shard_plaintext(
1480    shard_index: u64,
1481    file_rows: &[FileRow],
1482    frames: &[PayloadFrame],
1483    payloads: &[PayloadObject],
1484    options: WriterOptions,
1485) -> Result<PlannedIndexShard, FormatError> {
1486    let mut string_pool = Vec::new();
1487    let mut file_entries = Vec::with_capacity(file_rows.len());
1488    let mut required_frame_indexes = BTreeSet::new();
1489    for row in file_rows {
1490        let path_offset = u32_len(string_pool.len(), "FileEntry.path_offset")?;
1491        string_pool.extend_from_slice(&row.path);
1492        let (first_frame_index, frame_count) = member_frame_range(row.member_index, frames)?;
1493        for offset in 0..frame_count as u64 {
1494            required_frame_indexes.insert(checked_u64_add(
1495                first_frame_index,
1496                offset,
1497                "FileEntry.frame_count",
1498            )?);
1499        }
1500        file_entries.push(FileEntry {
1501            path_hash: row.path_hash,
1502            path_offset,
1503            path_length: u32_len(row.path.len(), "FileEntry.path_length")?,
1504            first_frame_index,
1505            frame_count,
1506            offset_in_first_frame_plaintext: 0,
1507            tar_member_group_size: row.member.tar_member_group_size,
1508            file_data_size: row.member.file_data_size,
1509            flags: 0,
1510        });
1511    }
1512
1513    let frame_entries = frames
1514        .iter()
1515        .filter(|frame| required_frame_indexes.contains(&frame.frame_index))
1516        .map(|frame| FrameEntry {
1517            frame_index: frame.frame_index,
1518            envelope_index: frame.envelope_index,
1519            offset_in_envelope: frame.offset_in_envelope,
1520            compressed_size: frame.compressed_size,
1521            decompressed_size: frame.decompressed_size,
1522            flags: frame.flags,
1523            tar_stream_offset: frame.tar_stream_offset,
1524        })
1525        .collect::<Vec<_>>();
1526    let required_envelope_indexes = frame_entries
1527        .iter()
1528        .map(|frame| frame.envelope_index)
1529        .collect::<BTreeSet<_>>();
1530    let envelope_entries = payloads
1531        .iter()
1532        .filter(|payload| required_envelope_indexes.contains(&payload.envelope_index))
1533        .map(|payload| {
1534            let (first_frame_index, frame_count) =
1535                envelope_frame_range(payload.envelope_index, frames)?;
1536            Ok(EnvelopeEntry {
1537                envelope_index: payload.envelope_index,
1538                first_block_index: payload.object.first_block_index,
1539                data_block_count: payload.object.data_block_count,
1540                parity_block_count: payload.object.parity_block_count,
1541                encrypted_size: payload.object.encrypted_size,
1542                plaintext_size: payload.plaintext_size,
1543                first_frame_index,
1544                frame_count,
1545            })
1546        })
1547        .collect::<Result<Vec<_>, FormatError>>()?;
1548
1549    let plaintext = serialize_index_shard(
1550        shard_index,
1551        &file_entries,
1552        &frame_entries,
1553        &envelope_entries,
1554        &string_pool,
1555        options,
1556    )?;
1557    let first_path_hash = file_rows
1558        .first()
1559        .ok_or(FormatError::WriterInvariant("empty planned IndexShard"))?
1560        .path_hash;
1561    let last_path_hash = file_rows
1562        .last()
1563        .ok_or(FormatError::WriterInvariant("empty planned IndexShard"))?
1564        .path_hash;
1565    Ok(PlannedIndexShard {
1566        shard_index,
1567        plaintext,
1568        file_count: u32_len(file_rows.len(), "IndexShard.file_count")?,
1569        first_path_hash,
1570        last_path_hash,
1571    })
1572}
1573
1574fn serialize_index_shard(
1575    shard_index: u64,
1576    files: &[FileEntry],
1577    frames: &[FrameEntry],
1578    envelopes: &[EnvelopeEntry],
1579    string_pool: &[u8],
1580    _options: WriterOptions,
1581) -> Result<Vec<u8>, FormatError> {
1582    let mut cursor = INDEX_SHARD_HEADER_LEN;
1583    let file_table_offset = table_offset(files.len(), cursor)?;
1584    cursor = checked_usize_add(cursor, files.len() * FILE_ENTRY_LEN, "IndexShard")?;
1585    let frame_table_offset = table_offset(frames.len(), cursor)?;
1586    cursor = checked_usize_add(cursor, frames.len() * FRAME_ENTRY_LEN, "IndexShard")?;
1587    let envelope_table_offset = table_offset(envelopes.len(), cursor)?;
1588    cursor = checked_usize_add(cursor, envelopes.len() * ENVELOPE_ENTRY_LEN, "IndexShard")?;
1589    let string_pool_offset = table_offset(string_pool.len(), cursor)?;
1590
1591    let header = IndexShardHeader {
1592        version: 1,
1593        shard_index,
1594        file_count: u32_len(files.len(), "IndexShard.file_count")?,
1595        frame_count: u32_len(frames.len(), "IndexShard.frame_count")?,
1596        envelope_count: u32_len(envelopes.len(), "IndexShard.envelope_count")?,
1597        file_table_offset,
1598        frame_table_offset,
1599        envelope_table_offset,
1600        string_pool_offset,
1601        string_pool_size: u32_len(string_pool.len(), "IndexShard.string_pool_size")?,
1602    };
1603
1604    let mut bytes = Vec::with_capacity(cursor + string_pool.len());
1605    bytes.extend_from_slice(&header.to_bytes());
1606    for entry in files {
1607        bytes.extend_from_slice(&entry.to_bytes());
1608    }
1609    for entry in frames {
1610        bytes.extend_from_slice(&entry.to_bytes());
1611    }
1612    for entry in envelopes {
1613        bytes.extend_from_slice(&entry.to_bytes());
1614    }
1615    bytes.extend_from_slice(string_pool);
1616    Ok(bytes)
1617}
1618
1619fn build_directory_hint_plaintexts(
1620    shard_rows: &[Vec<FileRow>],
1621    options: WriterOptions,
1622) -> Result<Vec<PlannedDirectoryHintShard>, FormatError> {
1623    let mut map = BTreeMap::<Vec<u8>, BTreeSet<u32>>::new();
1624    for (shard_row_index, rows) in shard_rows.iter().enumerate() {
1625        let shard_row_index = u32::try_from(shard_row_index)
1626            .map_err(|_| FormatError::WriterUnsupported("directory hint shard row index"))?;
1627        for row in rows {
1628            add_directory_hint_rows(&mut map, shard_row_index, &row.path);
1629        }
1630    }
1631
1632    let rows = map
1633        .into_iter()
1634        .map(|(path, shard_rows)| (hash_prefix(&path), path, shard_rows))
1635        .collect::<Vec<_>>();
1636    let mut rows = rows;
1637    rows.sort_by(|left, right| (left.0, left.1.as_slice()).cmp(&(right.0, right.1.as_slice())));
1638
1639    let mut planned = Vec::new();
1640    for chunk in rows.chunks(DEFAULT_DIRECTORY_HINT_ENTRIES_PER_SHARD) {
1641        append_directory_hint_shards_for_rows(&mut planned, chunk.to_vec(), options)?;
1642    }
1643    Ok(planned)
1644}
1645
1646fn append_directory_hint_shards_for_rows(
1647    planned: &mut Vec<PlannedDirectoryHintShard>,
1648    rows: Vec<([u8; 8], Vec<u8>, BTreeSet<u32>)>,
1649    options: WriterOptions,
1650) -> Result<(), FormatError> {
1651    let hint_shard_index = u64::try_from(planned.len())
1652        .map_err(|_| FormatError::WriterUnsupported("hint_shard_index"))?;
1653    let candidate = build_directory_hint_plaintext(hint_shard_index, &rows)?;
1654    let compressed = compress_zstd_frame(&candidate.plaintext, options.zstd_level)?;
1655    if index_object_can_fit(compressed.len(), options)? {
1656        planned.push(candidate);
1657        return Ok(());
1658    }
1659    if rows.len() == 1 {
1660        return Err(FormatError::WriterUnsupported(
1661            "single DirectoryHintEntry exceeds index object limits",
1662        ));
1663    }
1664    let split_at = rows.len() / 2;
1665    append_directory_hint_shards_for_rows(planned, rows[..split_at].to_vec(), options)?;
1666    append_directory_hint_shards_for_rows(planned, rows[split_at..].to_vec(), options)
1667}
1668
1669fn add_directory_hint_rows(
1670    map: &mut BTreeMap<Vec<u8>, BTreeSet<u32>>,
1671    shard_row_index: u32,
1672    path: &[u8],
1673) {
1674    map.entry(Vec::new()).or_default().insert(shard_row_index);
1675    let mut cursor = 0usize;
1676    while let Some(position) = path[cursor..].iter().position(|byte| *byte == b'/') {
1677        let slash = cursor + position;
1678        if slash > 0 {
1679            map.entry(path[..slash].to_vec())
1680                .or_default()
1681                .insert(shard_row_index);
1682        }
1683        cursor = slash + 1;
1684    }
1685}
1686
1687fn build_directory_hint_plaintext(
1688    hint_shard_index: u64,
1689    rows: &[([u8; 8], Vec<u8>, BTreeSet<u32>)],
1690) -> Result<PlannedDirectoryHintShard, FormatError> {
1691    let mut entries = Vec::with_capacity(rows.len());
1692    let mut shard_row_indexes = Vec::new();
1693    let mut string_pool = Vec::new();
1694
1695    for (dir_hash, path, shard_rows) in rows {
1696        let path_offset = if path.is_empty() {
1697            0
1698        } else {
1699            u64::try_from(string_pool.len())
1700                .map_err(|_| FormatError::WriterUnsupported("DirectoryHintEntry.path_offset"))?
1701        };
1702        if !path.is_empty() {
1703            string_pool.extend_from_slice(path);
1704        }
1705        let shard_list_start_index = u32_len(
1706            shard_row_indexes.len(),
1707            "DirectoryHintEntry.shard_list_start_index",
1708        )?;
1709        shard_row_indexes.extend(shard_rows.iter().copied());
1710        entries.push(DirectoryHintEntry {
1711            dir_hash: *dir_hash,
1712            path_offset,
1713            path_length: u32_len(path.len(), "DirectoryHintEntry.path_length")?,
1714            shard_list_start_index,
1715            shard_count: u32_len(shard_rows.len(), "DirectoryHintEntry.shard_count")?,
1716        });
1717    }
1718
1719    let plaintext = serialize_directory_hint_table(
1720        hint_shard_index,
1721        &entries,
1722        &shard_row_indexes,
1723        &string_pool,
1724    )?;
1725    let first_dir_hash = rows
1726        .first()
1727        .ok_or(FormatError::WriterInvariant("empty directory hint shard"))?
1728        .0;
1729    let last_dir_hash = rows
1730        .last()
1731        .ok_or(FormatError::WriterInvariant("empty directory hint shard"))?
1732        .0;
1733    Ok(PlannedDirectoryHintShard {
1734        hint_shard_index,
1735        plaintext,
1736        entry_count: rows.len() as u64,
1737        first_dir_hash,
1738        last_dir_hash,
1739    })
1740}
1741
1742fn serialize_directory_hint_table(
1743    hint_shard_index: u64,
1744    entries: &[DirectoryHintEntry],
1745    shard_row_indexes: &[u32],
1746    string_pool: &[u8],
1747) -> Result<Vec<u8>, FormatError> {
1748    let entry_table_offset = table_offset(entries.len(), DIRECTORY_HINT_TABLE_LEN)?;
1749    let shard_list_cursor = checked_usize_add(
1750        DIRECTORY_HINT_TABLE_LEN,
1751        entries.len() * DIRECTORY_HINT_ENTRY_LEN,
1752        "DirectoryHintTable",
1753    )?;
1754    let shard_list_offset = table_offset(shard_row_indexes.len(), shard_list_cursor)?;
1755    let string_pool_cursor = checked_usize_add(
1756        shard_list_cursor,
1757        shard_row_indexes.len() * 4,
1758        "DirectoryHintTable",
1759    )?;
1760    let string_pool_offset = if string_pool.is_empty() {
1761        0
1762    } else {
1763        u64::try_from(string_pool_cursor)
1764            .map_err(|_| FormatError::WriterUnsupported("DirectoryHintTable.string_pool_offset"))?
1765    };
1766    let header = DirectoryHintTableHeader {
1767        version: 1,
1768        hint_shard_index,
1769        entry_count: entries.len() as u64,
1770        entry_table_offset: entry_table_offset as u64,
1771        shard_list_offset: shard_list_offset as u64,
1772        string_pool_offset,
1773        string_pool_size: string_pool.len() as u64,
1774    };
1775
1776    let mut bytes = Vec::with_capacity(string_pool_cursor + string_pool.len());
1777    bytes.extend_from_slice(&header.to_bytes());
1778    for entry in entries {
1779        bytes.extend_from_slice(&entry.to_bytes());
1780    }
1781    for row in shard_row_indexes {
1782        bytes.extend_from_slice(&row.to_le_bytes());
1783    }
1784    bytes.extend_from_slice(string_pool);
1785    Ok(bytes)
1786}
1787
1788fn build_index_root_plaintext(
1789    shard_entries: &[ShardEntry],
1790    frame_count: u64,
1791    envelope_count: u64,
1792    file_count: u64,
1793    payload_block_count: u64,
1794    tar_total_size: u64,
1795    content_sha256: [u8; 32],
1796    directory_hint_entries: &[DirectoryHintShardEntry],
1797    dictionary_extent: Option<(ObjectExtent, u32)>,
1798) -> Vec<u8> {
1799    let mut header = IndexRootHeader::empty();
1800    header.frame_count = frame_count;
1801    header.envelope_count = envelope_count;
1802    header.file_count = file_count;
1803    header.payload_block_count = payload_block_count;
1804    header.tar_total_size = tar_total_size;
1805    header.content_sha256 = content_sha256;
1806    if let Some((dictionary, decompressed_size)) = dictionary_extent {
1807        header.dictionary_first_block = dictionary.first_block_index;
1808        header.dictionary_data_block_count = dictionary.data_block_count;
1809        header.dictionary_parity_block_count = dictionary.parity_block_count;
1810        header.dictionary_encrypted_size = dictionary.encrypted_size;
1811        header.dictionary_decompressed_size = decompressed_size;
1812    }
1813    let root = IndexRoot {
1814        header,
1815        shards: shard_entries.to_vec(),
1816        directory_hint_shards: directory_hint_entries.to_vec(),
1817    };
1818    root.to_bytes()
1819}
1820
1821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1822struct MetadataClassPlan {
1823    options: WriterOptions,
1824    index_root: PlannedEncryptedObject,
1825    dictionary: Option<PlannedEncryptedObject>,
1826}
1827
1828fn plan_index_root_metadata_class(
1829    mut options: WriterOptions,
1830    compressed_index_root_len: usize,
1831    compressed_dictionary_len: Option<usize>,
1832) -> Result<MetadataClassPlan, FormatError> {
1833    let index_root = plan_metadata_object_without_class(
1834        compressed_index_root_len,
1835        options,
1836        MetadataObjectKind::IndexRoot,
1837    )?;
1838    let dictionary = compressed_dictionary_len
1839        .map(|len| plan_metadata_object_without_class(len, options, MetadataObjectKind::Dictionary))
1840        .transpose()?;
1841    let required_data_shards = u32::from(options.index_root_fec_data_shards)
1842        .max(MIN_INDEX_ROOT_FEC_DATA_SHARDS as u32)
1843        .max(index_root.data_block_count)
1844        .max(dictionary.map(|plan| plan.data_block_count).unwrap_or(0));
1845    let required_data_shards = u16::try_from(required_data_shards)
1846        .map_err(|_| MetadataObjectKind::IndexRoot.too_large_error())?;
1847    options.index_root_fec_data_shards = required_data_shards;
1848    let required_parity_shards = compute_parity_u16(
1849        options.index_root_fec_data_shards as u64,
1850        options,
1851        "index_root_fec_parity_shards",
1852    )?;
1853    options.index_root_fec_parity_shards = options
1854        .index_root_fec_parity_shards
1855        .max(required_parity_shards);
1856    ensure_metadata_object_fits_class(index_root, options, MetadataObjectKind::IndexRoot)?;
1857    if let Some(dictionary) = dictionary {
1858        ensure_metadata_object_fits_class(dictionary, options, MetadataObjectKind::Dictionary)?;
1859    }
1860    Ok(MetadataClassPlan {
1861        options,
1862        index_root,
1863        dictionary,
1864    })
1865}
1866
1867fn plan_metadata_object_without_class(
1868    payload_len: usize,
1869    options: WriterOptions,
1870    kind: MetadataObjectKind,
1871) -> Result<PlannedEncryptedObject, FormatError> {
1872    let plan = plan_encrypted_object_without_class(payload_len, options)
1873        .map_err(|_| kind.too_large_error())?;
1874    if plan.data_block_count > u16::MAX as u32 || plan.parity_block_count > u16::MAX as u32 {
1875        return Err(kind.too_large_error());
1876    }
1877    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)
1878        .map_err(|_| kind.too_large_error())?;
1879    Ok(plan)
1880}
1881
1882fn ensure_metadata_object_fits_class(
1883    plan: PlannedEncryptedObject,
1884    options: WriterOptions,
1885    kind: MetadataObjectKind,
1886) -> Result<(), FormatError> {
1887    if plan.data_block_count > options.index_root_fec_data_shards as u32 {
1888        return Err(kind.too_large_error());
1889    }
1890    if plan.parity_block_count > options.index_root_fec_parity_shards as u32 {
1891        return Err(kind.too_large_error());
1892    }
1893    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)
1894        .map_err(|_| kind.too_large_error())
1895}
1896
1897fn payload_object_can_fit(payload_len: usize, options: WriterOptions) -> Result<bool, FormatError> {
1898    encrypted_object_can_fit(
1899        payload_len,
1900        options.fec_data_shards,
1901        options.fec_parity_shards,
1902        options,
1903    )
1904}
1905
1906fn index_object_can_fit(payload_len: usize, options: WriterOptions) -> Result<bool, FormatError> {
1907    encrypted_object_can_fit(
1908        payload_len,
1909        options.index_fec_data_shards,
1910        options.index_fec_parity_shards,
1911        options,
1912    )
1913}
1914
1915fn encrypted_object_can_fit(
1916    payload_len: usize,
1917    data_shard_max: u16,
1918    parity_shard_max: u16,
1919    options: WriterOptions,
1920) -> Result<bool, FormatError> {
1921    match plan_encrypted_object(payload_len, data_shard_max, parity_shard_max, options) {
1922        Ok(_) => Ok(true),
1923        Err(FormatError::WriterUnsupported("encrypted object exceeds u32 size limit"))
1924        | Err(FormatError::WriterUnsupported(
1925            "encrypted object exceeds its data shard class maximum",
1926        ))
1927        | Err(FormatError::WriterUnsupported(
1928            "encrypted object exceeds its parity shard class maximum",
1929        ))
1930        | Err(FormatError::WriterUnsupported(
1931            "encrypted object exceeds ReedSolomonGF16 shard limit",
1932        )) => Ok(false),
1933        Err(err) => Err(err),
1934    }
1935}
1936
1937fn plan_encrypted_object(
1938    payload_len: usize,
1939    data_shard_max: u16,
1940    parity_shard_max: u16,
1941    options: WriterOptions,
1942) -> Result<PlannedEncryptedObject, FormatError> {
1943    let plan = plan_encrypted_object_without_class(payload_len, options)?;
1944    if plan.data_block_count > data_shard_max as u32 {
1945        return Err(FormatError::WriterUnsupported(
1946            "encrypted object exceeds its data shard class maximum",
1947        ));
1948    }
1949    if plan.parity_block_count > parity_shard_max as u32 {
1950        return Err(FormatError::WriterUnsupported(
1951            "encrypted object exceeds its parity shard class maximum",
1952        ));
1953    }
1954    validate_object_shard_total(plan.data_block_count, plan.parity_block_count)?;
1955    Ok(plan)
1956}
1957
1958fn plan_encrypted_object_without_class(
1959    payload_len: usize,
1960    options: WriterOptions,
1961) -> Result<PlannedEncryptedObject, FormatError> {
1962    let (data_block_count, encrypted_size) = encrypted_object_data_extent(payload_len, options)?;
1963    let parity_block_count = compute_parity(data_block_count as u64, options)?;
1964    Ok(PlannedEncryptedObject {
1965        data_block_count,
1966        parity_block_count,
1967        encrypted_size,
1968    })
1969}
1970
1971fn encrypted_object_data_extent(
1972    payload_len: usize,
1973    options: WriterOptions,
1974) -> Result<(u32, u32), FormatError> {
1975    let block_size = options.block_size as usize;
1976    let tag_len = options.aead_algo.tag_len();
1977    let total_before_padding =
1978        payload_len
1979            .checked_add(tag_len)
1980            .ok_or(FormatError::WriterUnsupported(
1981                "encrypted object size overflow",
1982            ))?;
1983    let remainder = total_before_padding % block_size;
1984    let encrypted_size = if remainder == 0 {
1985        total_before_padding
1986            .checked_add(block_size)
1987            .ok_or(FormatError::WriterUnsupported(
1988                "encrypted object size overflow",
1989            ))?
1990    } else {
1991        total_before_padding
1992            .checked_add(block_size - remainder)
1993            .ok_or(FormatError::WriterUnsupported(
1994                "encrypted object size overflow",
1995            ))?
1996    };
1997    let encrypted_size = u32_len(encrypted_size, "encrypted_size")
1998        .map_err(|_| FormatError::WriterUnsupported("encrypted object exceeds u32 size limit"))?;
1999    Ok((encrypted_size / options.block_size, encrypted_size))
2000}
2001
2002fn encrypt_object(
2003    payload: &[u8],
2004    key: &[u8; 32],
2005    nonce_seed: &[u8; 32],
2006    domain: &[u8],
2007    counter: u64,
2008    data_kind: BlockKind,
2009    parity_kind: BlockKind,
2010    data_shard_max: u16,
2011    class_parity_shard_max: u16,
2012    next_block_index: &mut u64,
2013    options: WriterOptions,
2014    archive_uuid: &[u8; 16],
2015    session_id: &[u8; 16],
2016) -> Result<EncryptedObject, FormatError> {
2017    let block_size = options.block_size as usize;
2018    let padded = suffix_pad_for_aead(payload, options.aead_algo.tag_len(), block_size)?;
2019    let nonce = derive_nonce(
2020        nonce_seed,
2021        domain,
2022        archive_uuid,
2023        session_id,
2024        counter,
2025        options.aead_algo.nonce_len(),
2026    )?;
2027    let aad = build_aad(domain, archive_uuid, session_id, counter)?;
2028    let encrypted = aead_encrypt(options.aead_algo, key, &nonce, &aad, &padded)?;
2029    if encrypted.len() % block_size != 0 {
2030        return Err(FormatError::WriterInvariant(
2031            "encrypted object is not block aligned",
2032        ));
2033    }
2034    let encrypted_size = u32_len(encrypted.len(), "encrypted_size")?;
2035    let data_shards = encrypted
2036        .chunks(block_size)
2037        .map(|chunk| chunk.to_vec())
2038        .collect::<Vec<_>>();
2039    let data_block_count = u32_len(data_shards.len(), "data_block_count")?;
2040    if data_block_count == 0 {
2041        return Err(FormatError::WriterInvariant(
2042            "encrypted object has no data blocks",
2043        ));
2044    }
2045    if data_block_count > data_shard_max as u32 {
2046        return Err(FormatError::WriterUnsupported(
2047            "encrypted object exceeds its data shard class maximum",
2048        ));
2049    }
2050    let required_parity = compute_object_parity(
2051        data_block_count as u64,
2052        options,
2053        class_parity_shard_max as u32,
2054    )?;
2055    if required_parity > class_parity_shard_max as u32 {
2056        return Err(FormatError::WriterUnsupported(
2057            "encrypted object exceeds its parity shard class maximum",
2058        ));
2059    }
2060    validate_object_shard_total(data_block_count, required_parity)?;
2061    let parity_count = required_parity as u16;
2062    let parity_shards = if parity_count == 0 {
2063        Vec::new()
2064    } else {
2065        encode_parity_gf16(&data_shards, parity_count as usize)?
2066    };
2067
2068    let first_block_index = *next_block_index;
2069    let mut records = Vec::with_capacity(data_shards.len() + parity_shards.len());
2070    for (index, payload) in data_shards.into_iter().enumerate() {
2071        records.push(BlockRecord {
2072            block_index: checked_u64_add(first_block_index, index as u64, "BlockRecord")?,
2073            kind: data_kind,
2074            flags: if index + 1 == data_block_count as usize {
2075                0x01
2076            } else {
2077                0
2078            },
2079            payload,
2080            record_crc32c: 0,
2081        });
2082    }
2083    let parity_first_block = checked_u64_add(first_block_index, data_block_count as u64, "FEC")?;
2084    for (index, payload) in parity_shards.into_iter().enumerate() {
2085        records.push(BlockRecord {
2086            block_index: checked_u64_add(parity_first_block, index as u64, "BlockRecord")?,
2087            kind: parity_kind,
2088            flags: 0,
2089            payload,
2090            record_crc32c: 0,
2091        });
2092    }
2093
2094    *next_block_index = checked_u64_add(
2095        first_block_index,
2096        data_block_count as u64 + parity_count as u64,
2097        "next_block_index",
2098    )?;
2099
2100    Ok(EncryptedObject {
2101        first_block_index,
2102        data_block_count,
2103        parity_block_count: parity_count as u32,
2104        encrypted_size,
2105        records,
2106    })
2107}
2108
2109fn validate_planned_object(
2110    object: &EncryptedObject,
2111    expected: PlannedEncryptedObject,
2112) -> Result<(), FormatError> {
2113    if object.data_block_count != expected.data_block_count
2114        || object.parity_block_count != expected.parity_block_count
2115        || object.encrypted_size != expected.encrypted_size
2116    {
2117        return Err(FormatError::WriterInvariant(
2118            "encrypted object did not match planned sizing",
2119        ));
2120    }
2121    Ok(())
2122}
2123
2124fn validate_planned_extent(
2125    object: &EncryptedObject,
2126    expected: ObjectExtent,
2127) -> Result<(), FormatError> {
2128    validate_planned_object(
2129        object,
2130        PlannedEncryptedObject {
2131            data_block_count: expected.data_block_count,
2132            parity_block_count: expected.parity_block_count,
2133            encrypted_size: expected.encrypted_size,
2134        },
2135    )?;
2136    if object.first_block_index != expected.first_block_index {
2137        return Err(FormatError::WriterInvariant(
2138            "encrypted object did not match planned extent",
2139        ));
2140    }
2141    Ok(())
2142}
2143
2144fn map_metadata_encrypt_error(error: FormatError, kind: MetadataObjectKind) -> FormatError {
2145    match error {
2146        FormatError::WriterUnsupported("encrypted object exceeds u32 size limit")
2147        | FormatError::WriterUnsupported("encrypted object exceeds its data shard class maximum")
2148        | FormatError::WriterUnsupported(
2149            "encrypted object exceeds its parity shard class maximum",
2150        )
2151        | FormatError::WriterUnsupported("encrypted object exceeds ReedSolomonGF16 shard limit") => {
2152            kind.too_large_error()
2153        }
2154        other => other,
2155    }
2156}
2157
2158fn build_root_auth_footer(
2159    config: RootAuthWriterConfig<'_>,
2160    authenticator: &mut dyn FnMut(&RootAuthSigningRequest) -> Result<Vec<u8>, FormatError>,
2161    archive_uuid: [u8; 16],
2162    session_id: [u8; 16],
2163    options: WriterOptions,
2164    crypto_header: &[u8],
2165    volume_zero_manifest: &[u8; MANIFEST_FOOTER_LEN],
2166    index_root_plaintext: &[u8],
2167    index_root_extent: &EncryptedObject,
2168    dictionary_extent: Option<(&EncryptedObject, u32)>,
2169    shard_entries: &[ShardEntry],
2170    payload_objects: &[PayloadObject],
2171    directory_hint_entries: &[DirectoryHintShardEntry],
2172    block_records: &[BlockRecord],
2173) -> Result<Vec<u8>, FormatError> {
2174    let parsed_crypto =
2175        CryptoHeader::parse(crypto_header, u32_len(crypto_header.len(), "CryptoHeader")?)?;
2176    let footer_length = root_auth_footer_wire_length(
2177        config.signer_identity.len(),
2178        config.authenticator_value_length as usize,
2179    )?;
2180    let root_auth_descriptor_digest = root_auth_descriptor_digest(
2181        config.authenticator_id,
2182        config.signer_identity_type,
2183        config.signer_identity,
2184        config.authenticator_value_length,
2185        footer_length,
2186    )?;
2187    let signer_identity_digest =
2188        signer_identity_digest(config.signer_identity_type, config.signer_identity)?;
2189    let manifest_pre_hmac = manifest_footer_global_pre_hmac_bytes(volume_zero_manifest);
2190    let critical_metadata_digest = critical_metadata_digest(CriticalMetadataDigestInputs {
2191        archive_uuid,
2192        session_id,
2193        stripe_width: options.stripe_width,
2194        total_volumes: options.stripe_width,
2195        compression_algo: parsed_crypto.fixed.compression_algo,
2196        aead_algo: parsed_crypto.fixed.aead_algo,
2197        fec_algo: parsed_crypto.fixed.fec_algo,
2198        kdf_algo: parsed_crypto.fixed.kdf_algo,
2199        crypto_header_pre_hmac_bytes: parsed_crypto.hmac_covered_bytes,
2200        chunk_size: parsed_crypto.fixed.chunk_size,
2201        envelope_target_size: parsed_crypto.fixed.envelope_target_size,
2202        block_size: parsed_crypto.fixed.block_size,
2203        fec_data_shards: parsed_crypto.fixed.fec_data_shards,
2204        fec_parity_shards: parsed_crypto.fixed.fec_parity_shards,
2205        index_fec_data_shards: parsed_crypto.fixed.index_fec_data_shards,
2206        index_fec_parity_shards: parsed_crypto.fixed.index_fec_parity_shards,
2207        index_root_fec_data_shards: parsed_crypto.fixed.index_root_fec_data_shards,
2208        index_root_fec_parity_shards: parsed_crypto.fixed.index_root_fec_parity_shards,
2209        volume_loss_tolerance: parsed_crypto.fixed.volume_loss_tolerance,
2210        bit_rot_buffer_pct: parsed_crypto.fixed.bit_rot_buffer_pct,
2211        has_dictionary: parsed_crypto.fixed.has_dictionary,
2212        manifest_footer_global_pre_hmac_bytes: &manifest_pre_hmac,
2213        index_root_first_block: index_root_extent.first_block_index,
2214        index_root_data_block_count: index_root_extent.data_block_count,
2215        index_root_parity_block_count: index_root_extent.parity_block_count,
2216        index_root_encrypted_size: index_root_extent.encrypted_size,
2217        index_root_decompressed_size: u32_len(index_root_plaintext.len(), "IndexRoot")?,
2218        root_auth_descriptor_digest,
2219    })?;
2220    let index_digest = index_digest(index_root_plaintext);
2221    let fec_layout_rows = writer_fec_layout_rows(
2222        index_root_extent,
2223        u32_len(index_root_plaintext.len(), "IndexRoot")?,
2224        dictionary_extent,
2225        shard_entries,
2226        payload_objects,
2227        directory_hint_entries,
2228    );
2229    let fec_layout_digest = fec_layout_digest(&fec_layout_rows)?;
2230    let mut data_leaves = block_records
2231        .iter()
2232        .filter(|record| record.kind.is_data())
2233        .map(|record| DataBlockMerkleLeaf {
2234            block_index: record.block_index,
2235            kind: record.kind,
2236            flags: record.flags,
2237            payload: record.payload.clone(),
2238        })
2239        .collect::<Vec<_>>();
2240    data_leaves.sort_by_key(|leaf| leaf.block_index);
2241    let total_data_block_count = u64::try_from(data_leaves.len())
2242        .map_err(|_| FormatError::WriterUnsupported("root-auth data block count"))?;
2243    let data_block_merkle_root = data_block_merkle_root(&data_leaves);
2244    let archive_root = archive_root(ArchiveRootInputs {
2245        archive_uuid,
2246        session_id,
2247        format_version: FORMAT_VERSION,
2248        volume_format_rev: VOLUME_FORMAT_REV,
2249        compression_algo: parsed_crypto.fixed.compression_algo,
2250        aead_algo: parsed_crypto.fixed.aead_algo,
2251        fec_algo: parsed_crypto.fixed.fec_algo,
2252        kdf_algo: parsed_crypto.fixed.kdf_algo,
2253        critical_metadata_digest,
2254        index_digest,
2255        fec_layout_digest,
2256        total_data_block_count,
2257        data_block_merkle_root,
2258        root_auth_descriptor_digest,
2259        signer_identity_digest,
2260    });
2261    let authenticator_value = authenticator(&RootAuthSigningRequest {
2262        archive_uuid,
2263        session_id,
2264        archive_root,
2265    })?;
2266    if authenticator_value.len() != config.authenticator_value_length as usize {
2267        return Err(FormatError::WriterUnsupported(
2268            "root-auth authenticator length mismatch",
2269        ));
2270    }
2271
2272    RootAuthFooterV1 {
2273        archive_uuid,
2274        session_id,
2275        authenticator_id: config.authenticator_id,
2276        signer_identity_type: config.signer_identity_type,
2277        signer_identity_bytes: config.signer_identity.to_vec(),
2278        authenticator_value,
2279        total_data_block_count,
2280        critical_metadata_digest,
2281        index_digest,
2282        fec_layout_digest,
2283        data_block_merkle_root,
2284        signer_identity_digest,
2285        archive_root,
2286        footer_crc32c: 0,
2287    }
2288    .to_bytes()
2289}
2290
2291fn writer_fec_layout_rows(
2292    index_root_extent: &EncryptedObject,
2293    index_root_plain_size: u32,
2294    dictionary_extent: Option<(&EncryptedObject, u32)>,
2295    shard_entries: &[ShardEntry],
2296    payload_objects: &[PayloadObject],
2297    directory_hint_entries: &[DirectoryHintShardEntry],
2298) -> Vec<FecLayoutObjectRow> {
2299    let mut rows = Vec::new();
2300    rows.push(FecLayoutObjectRow {
2301        object_class: 1,
2302        present: true,
2303        object_id: 0,
2304        first_block_index: index_root_extent.first_block_index,
2305        data_block_count: index_root_extent.data_block_count,
2306        parity_block_count: index_root_extent.parity_block_count,
2307        encrypted_size: index_root_extent.encrypted_size,
2308        plain_size: index_root_plain_size,
2309    });
2310    if let Some((dictionary, decompressed_size)) = dictionary_extent {
2311        rows.push(FecLayoutObjectRow {
2312            object_class: 2,
2313            present: true,
2314            object_id: 0,
2315            first_block_index: dictionary.first_block_index,
2316            data_block_count: dictionary.data_block_count,
2317            parity_block_count: dictionary.parity_block_count,
2318            encrypted_size: dictionary.encrypted_size,
2319            plain_size: decompressed_size,
2320        });
2321    } else {
2322        rows.push(FecLayoutObjectRow {
2323            object_class: 2,
2324            present: false,
2325            object_id: 0,
2326            first_block_index: 0,
2327            data_block_count: 0,
2328            parity_block_count: 0,
2329            encrypted_size: 0,
2330            plain_size: 0,
2331        });
2332    }
2333    for entry in shard_entries {
2334        rows.push(FecLayoutObjectRow {
2335            object_class: 3,
2336            present: true,
2337            object_id: entry.shard_index,
2338            first_block_index: entry.first_block_index,
2339            data_block_count: entry.data_block_count,
2340            parity_block_count: entry.parity_block_count,
2341            encrypted_size: entry.encrypted_size,
2342            plain_size: entry.decompressed_size,
2343        });
2344    }
2345    for payload in payload_objects {
2346        rows.push(FecLayoutObjectRow {
2347            object_class: 4,
2348            present: true,
2349            object_id: payload.envelope_index,
2350            first_block_index: payload.object.first_block_index,
2351            data_block_count: payload.object.data_block_count,
2352            parity_block_count: payload.object.parity_block_count,
2353            encrypted_size: payload.object.encrypted_size,
2354            plain_size: payload.plaintext_size,
2355        });
2356    }
2357    for entry in directory_hint_entries {
2358        rows.push(FecLayoutObjectRow {
2359            object_class: 5,
2360            present: true,
2361            object_id: entry.hint_shard_index,
2362            first_block_index: entry.first_block_index,
2363            data_block_count: entry.data_block_count,
2364            parity_block_count: entry.parity_block_count,
2365            encrypted_size: entry.encrypted_size,
2366            plain_size: entry.decompressed_size,
2367        });
2368    }
2369    rows
2370}
2371
2372fn manifest_footer_global_pre_hmac_bytes(manifest_footer: &[u8; MANIFEST_FOOTER_LEN]) -> [u8; 104] {
2373    let mut bytes = [0u8; 104];
2374    bytes.copy_from_slice(&manifest_footer[..104]);
2375    bytes[36..40].fill(0);
2376    bytes
2377}
2378
2379fn root_auth_footer_wire_length(
2380    signer_identity_len: usize,
2381    authenticator_value_len: usize,
2382) -> Result<u32, FormatError> {
2383    let len = crate::format::ROOT_AUTH_FOOTER_FIXED_LEN
2384        .checked_add(signer_identity_len)
2385        .and_then(|value| value.checked_add(authenticator_value_len))
2386        .and_then(|value| value.checked_add(4))
2387        .ok_or(FormatError::WriterUnsupported(
2388            "RootAuthFooterV1 length overflow",
2389        ))?;
2390    u32::try_from(len).map_err(|_| FormatError::WriterUnsupported("RootAuthFooterV1 length"))
2391}
2392
2393fn build_manifest_footer(
2394    subkeys: &Subkeys,
2395    archive_uuid: [u8; 16],
2396    session_id: [u8; 16],
2397    volume_index: u32,
2398    total_volumes: u32,
2399    index_root_extent: &EncryptedObject,
2400    index_root_decompressed_size: usize,
2401) -> Result<[u8; MANIFEST_FOOTER_LEN], FormatError> {
2402    let mut footer = ManifestFooter {
2403        archive_uuid,
2404        session_id,
2405        volume_index,
2406        is_authoritative: 1,
2407        total_volumes,
2408        index_root_first_block: index_root_extent.first_block_index,
2409        index_root_data_block_count: index_root_extent.data_block_count,
2410        index_root_parity_block_count: index_root_extent.parity_block_count,
2411        index_root_encrypted_size: index_root_extent.encrypted_size,
2412        index_root_decompressed_size: u32_len(index_root_decompressed_size, "IndexRoot")?,
2413        manifest_hmac: [0u8; 32],
2414    };
2415    let mut bytes = footer.to_bytes();
2416    footer.manifest_hmac = compute_hmac(
2417        HmacDomain::ManifestFooter,
2418        &subkeys.mac_key,
2419        &archive_uuid,
2420        &session_id,
2421        &bytes[..104],
2422    );
2423    bytes = footer.to_bytes();
2424    Ok(bytes)
2425}
2426
2427fn build_volume_trailer(
2428    subkeys: &Subkeys,
2429    archive_uuid: [u8; 16],
2430    session_id: [u8; 16],
2431    volume_index: u32,
2432    block_count: u64,
2433    bytes_written: u64,
2434    manifest_footer_offset: u64,
2435    closed_at_ns: i64,
2436    root_auth_footer: Option<(u64, u32)>,
2437) -> [u8; VOLUME_TRAILER_LEN] {
2438    let (root_auth_footer_offset, root_auth_footer_length, root_auth_flags) = match root_auth_footer
2439    {
2440        Some((offset, length)) => (offset, length, 0x0000_0001),
2441        None => (0, 0, 0),
2442    };
2443    let mut trailer = VolumeTrailer {
2444        archive_uuid,
2445        session_id,
2446        volume_index,
2447        block_count,
2448        bytes_written,
2449        manifest_footer_offset,
2450        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
2451        closed_at_ns,
2452        root_auth_footer_offset,
2453        root_auth_footer_length,
2454        root_auth_flags,
2455        trailer_hmac: [0u8; 32],
2456    };
2457    let mut bytes = trailer.to_bytes();
2458    trailer.trailer_hmac = compute_hmac(
2459        HmacDomain::VolumeTrailer,
2460        &subkeys.mac_key,
2461        &archive_uuid,
2462        &session_id,
2463        &bytes[..96],
2464    );
2465    bytes = trailer.to_bytes();
2466    bytes
2467}
2468
2469struct BuiltCmra {
2470    bytes: Vec<u8>,
2471    shard_size: u32,
2472    data_shard_count: u16,
2473    parity_shard_count: u16,
2474    image_length: u32,
2475    image_sha256: [u8; 32],
2476}
2477
2478fn build_v41_cmra(
2479    volume_header_bytes: &[u8; VOLUME_HEADER_LEN],
2480    crypto_header: &[u8],
2481    block_count: u64,
2482    manifest_footer_offset: u64,
2483    manifest_footer: &[u8; MANIFEST_FOOTER_LEN],
2484    root_auth_footer_offset: Option<u64>,
2485    root_auth_footer: Option<&[u8]>,
2486    trailer_offset: u64,
2487    trailer: &[u8; VOLUME_TRAILER_LEN],
2488    cmra_offset: u64,
2489    options: WriterOptions,
2490    archive_uuid: [u8; 16],
2491    session_id: [u8; 16],
2492    volume_index: u32,
2493) -> Result<BuiltCmra, FormatError> {
2494    let block_record_len = options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
2495    let block_records_offset = VOLUME_HEADER_LEN as u64 + crypto_header.len() as u64;
2496    let block_records_length = checked_u64_mul(
2497        block_count,
2498        block_record_len,
2499        "CMRA BlockRecord length overflow",
2500    )?;
2501    let manifest_end = manifest_footer_offset
2502        .checked_add(MANIFEST_FOOTER_LEN as u64)
2503        .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?;
2504    let root_auth_footer_length = root_auth_footer
2505        .map(|footer| u32_len(footer.len(), "RootAuthFooterV1"))
2506        .transpose()?;
2507    match (root_auth_footer_offset, root_auth_footer_length) {
2508        (Some(offset), Some(length)) => {
2509            if manifest_end != offset
2510                || offset
2511                    .checked_add(length as u64)
2512                    .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?
2513                    != trailer_offset
2514            {
2515                return Err(FormatError::WriterInvariant(
2516                    "RootAuthFooter does not sit between ManifestFooter and VolumeTrailer",
2517                ));
2518            }
2519        }
2520        (None, None) => {
2521            if manifest_end != trailer_offset {
2522                return Err(FormatError::WriterInvariant(
2523                    "ManifestFooter does not end at VolumeTrailer",
2524                ));
2525            }
2526        }
2527        _ => {
2528            return Err(FormatError::WriterInvariant(
2529                "RootAuthFooter offset/bytes mismatch",
2530            ));
2531        }
2532    }
2533    let body_bytes_before_cmra = trailer_offset
2534        .checked_add(VOLUME_TRAILER_LEN as u64)
2535        .ok_or(FormatError::WriterUnsupported("CMRA terminal overflow"))?;
2536    if body_bytes_before_cmra != cmra_offset {
2537        return Err(FormatError::WriterInvariant(
2538            "CMRA does not start after VolumeTrailer",
2539        ));
2540    }
2541
2542    let mut regions = vec![
2543        SerializedRegion {
2544            region_type: 1,
2545            offset: 0,
2546            bytes: volume_header_bytes.to_vec(),
2547        },
2548        SerializedRegion {
2549            region_type: 2,
2550            offset: VOLUME_HEADER_LEN as u64,
2551            bytes: crypto_header.to_vec(),
2552        },
2553        SerializedRegion {
2554            region_type: 3,
2555            offset: manifest_footer_offset,
2556            bytes: manifest_footer.to_vec(),
2557        },
2558    ];
2559    if let (Some(offset), Some(footer)) = (root_auth_footer_offset, root_auth_footer) {
2560        regions.push(SerializedRegion {
2561            region_type: 4,
2562            offset,
2563            bytes: footer.to_vec(),
2564        });
2565    }
2566    regions.push(SerializedRegion {
2567        region_type: 5,
2568        offset: trailer_offset,
2569        bytes: trailer.to_vec(),
2570    });
2571    let image = CriticalMetadataImage {
2572        archive_uuid,
2573        session_id,
2574        volume_index,
2575        stripe_width: options.stripe_width,
2576        layout_flags: if root_auth_footer.is_some() {
2577            0x0000_0001
2578        } else {
2579            0
2580        },
2581        volume_header_offset: 0,
2582        volume_header_length: VOLUME_HEADER_LEN as u32,
2583        crypto_header_offset: VOLUME_HEADER_LEN as u64,
2584        crypto_header_length: u32_len(crypto_header.len(), "CryptoHeader")?,
2585        block_records_offset,
2586        block_records_length,
2587        block_count,
2588        manifest_footer_offset,
2589        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
2590        root_auth_footer_offset: root_auth_footer_offset.unwrap_or(0),
2591        root_auth_footer_length: root_auth_footer_length.unwrap_or(0),
2592        volume_trailer_offset: trailer_offset,
2593        volume_trailer_length: VOLUME_TRAILER_LEN as u32,
2594        body_bytes_before_cmra,
2595        volume_header_sha256: sha256_bytes(volume_header_bytes),
2596        crypto_header_sha256: sha256_bytes(crypto_header),
2597        manifest_footer_sha256: sha256_bytes(manifest_footer),
2598        root_auth_footer_sha256: root_auth_footer.map(sha256_bytes).unwrap_or([0u8; 32]),
2599        volume_trailer_sha256: sha256_bytes(trailer),
2600        regions,
2601    };
2602    let image_bytes = image.to_bytes()?;
2603    let image_sha256 = sha256_bytes(&image_bytes);
2604    let data_shard_count = ceil_div(image_bytes.len() as u64, CMRA_SHARD_SIZE as u64)?;
2605    let data_shard_count_u16 = u16::try_from(data_shard_count)
2606        .map_err(|_| FormatError::WriterUnsupported("CMRA data shard count"))?;
2607    let parity_lower = cmra_min_parity_shards(data_shard_count, options.bit_rot_buffer_pct)?;
2608    let parity_upper = cmra_min_parity_shards(data_shard_count, READER_MAX_CMRA_PARITY_PCT as u8)?;
2609    if parity_lower > parity_upper {
2610        return Err(FormatError::WriterUnsupported("CMRA parity bounds"));
2611    }
2612    let parity_shard_count_u16 = u16::try_from(parity_lower)
2613        .map_err(|_| FormatError::WriterUnsupported("CMRA parity shard count"))?;
2614
2615    let mut data_shards = Vec::with_capacity(data_shard_count as usize);
2616    for idx in 0..data_shard_count as usize {
2617        let start = idx * CMRA_SHARD_SIZE;
2618        let end = (start + CMRA_SHARD_SIZE).min(image_bytes.len());
2619        let mut shard = vec![0u8; CMRA_SHARD_SIZE];
2620        if start < image_bytes.len() {
2621            shard[..end - start].copy_from_slice(&image_bytes[start..end]);
2622        }
2623        data_shards.push(shard);
2624    }
2625    let parity_shards = encode_parity_gf16(&data_shards, parity_shard_count_u16 as usize)?;
2626
2627    let header = CriticalMetadataRecoveryHeader {
2628        shard_size: CMRA_SHARD_SIZE as u32,
2629        data_shard_count: data_shard_count_u16,
2630        parity_shard_count: parity_shard_count_u16,
2631        image_length: u32_len(image_bytes.len(), "CriticalMetadataImageV1")?,
2632        archive_uuid_hint: archive_uuid,
2633        session_id_hint: session_id,
2634        volume_index_hint: volume_index,
2635        image_sha256,
2636        header_crc32c: 0,
2637    };
2638    let mut cmra = Vec::new();
2639    cmra.extend_from_slice(&header.to_bytes());
2640    for (idx, payload) in data_shards.into_iter().enumerate() {
2641        let payload_len = if idx + 1 == data_shard_count as usize {
2642            let final_len = image_bytes.len() - idx * CMRA_SHARD_SIZE;
2643            if final_len == 0 {
2644                CMRA_SHARD_SIZE
2645            } else {
2646                final_len
2647            }
2648        } else {
2649            CMRA_SHARD_SIZE
2650        };
2651        cmra.extend_from_slice(
2652            &CriticalMetadataRecoveryShard {
2653                shard_index: u16::try_from(idx)
2654                    .map_err(|_| FormatError::WriterUnsupported("CMRA shard index"))?,
2655                shard_role: 0,
2656                shard_payload_length: u32_len(payload_len, "CMRA shard payload")?,
2657                payload,
2658                shard_crc32c: 0,
2659            }
2660            .to_bytes(CMRA_SHARD_SIZE)?,
2661        );
2662    }
2663    for (idx, payload) in parity_shards.into_iter().enumerate() {
2664        let shard_index = data_shard_count
2665            .checked_add(idx as u64)
2666            .ok_or(FormatError::WriterUnsupported("CMRA shard index overflow"))?;
2667        cmra.extend_from_slice(
2668            &CriticalMetadataRecoveryShard {
2669                shard_index: u16::try_from(shard_index)
2670                    .map_err(|_| FormatError::WriterUnsupported("CMRA shard index"))?,
2671                shard_role: 1,
2672                shard_payload_length: CMRA_SHARD_SIZE as u32,
2673                payload,
2674                shard_crc32c: 0,
2675            }
2676            .to_bytes(CMRA_SHARD_SIZE)?,
2677        );
2678    }
2679
2680    Ok(BuiltCmra {
2681        bytes: cmra,
2682        shard_size: CMRA_SHARD_SIZE as u32,
2683        data_shard_count: data_shard_count_u16,
2684        parity_shard_count: parity_shard_count_u16,
2685        image_length: u32_len(image_bytes.len(), "CriticalMetadataImageV1")?,
2686        image_sha256,
2687    })
2688}
2689
2690fn cmra_min_parity_shards(data_shard_count: u64, pct: u8) -> Result<u64, FormatError> {
2691    let by_pct = ceil_div(
2692        checked_u64_mul(data_shard_count, pct as u64, "CMRA parity overflow")?,
2693        100,
2694    )?;
2695    Ok(2u64.max(by_pct))
2696}
2697
2698fn compute_object_parity(
2699    data_block_count: u64,
2700    options: WriterOptions,
2701    class_parity_shard_max: u32,
2702) -> Result<u32, FormatError> {
2703    let computed = compute_parity(data_block_count, options)?;
2704    if computed > class_parity_shard_max {
2705        return Err(FormatError::WriterUnsupported(
2706            "encrypted object exceeds its parity shard class maximum",
2707        ));
2708    }
2709    Ok(computed)
2710}
2711
2712fn validate_object_shard_total(
2713    data_block_count: u32,
2714    parity_block_count: u32,
2715) -> Result<(), FormatError> {
2716    let total = checked_u64_add(
2717        data_block_count as u64,
2718        parity_block_count as u64,
2719        "encrypted object shard total overflow",
2720    )?;
2721    if total > MAX_REED_SOLOMON_GF16_SHARDS {
2722        return Err(FormatError::WriterUnsupported(
2723            "encrypted object exceeds ReedSolomonGF16 shard limit",
2724        ));
2725    }
2726    Ok(())
2727}
2728
2729fn compute_parity_u16(
2730    data_block_count: u64,
2731    options: WriterOptions,
2732    field: &'static str,
2733) -> Result<u16, FormatError> {
2734    let parity = compute_parity(data_block_count, options)?;
2735    u16::try_from(parity).map_err(|_| FormatError::WriterUnsupported(field))
2736}
2737
2738fn compute_parity(data_block_count: u64, options: WriterOptions) -> Result<u32, FormatError> {
2739    let min_parity = if options.volume_loss_tolerance > 0 || options.bit_rot_buffer_pct > 0 {
2740        1u64
2741    } else {
2742        0u64
2743    };
2744    let mut parity = 0u64;
2745    for _ in 0..100 {
2746        let total = data_block_count
2747            .checked_add(parity)
2748            .ok_or(FormatError::WriterUnsupported("parity total overflow"))?;
2749        let by_volume = checked_u64_mul(
2750            options.volume_loss_tolerance as u64,
2751            ceil_div(total, options.stripe_width as u64)?,
2752            "volume-loss parity overflow",
2753        )?;
2754        let by_bitrot = ceil_div(
2755            checked_u64_mul(
2756                total,
2757                options.bit_rot_buffer_pct as u64,
2758                "bit-rot parity overflow",
2759            )?,
2760            100,
2761        )?;
2762        let next = by_volume
2763            .checked_add(by_bitrot)
2764            .ok_or(FormatError::WriterUnsupported("parity overflow"))?
2765            .max(min_parity);
2766        if next == parity {
2767            return u32::try_from(next).map_err(|_| FormatError::WriterUnsupported("parity count"));
2768        }
2769        parity = next;
2770    }
2771    Err(FormatError::WriterUnsupported(
2772        "parity calculation did not converge",
2773    ))
2774}
2775
2776fn ceil_div(numerator: u64, denominator: u64) -> Result<u64, FormatError> {
2777    if denominator == 0 {
2778        return Err(FormatError::WriterUnsupported("division by zero"));
2779    }
2780    numerator
2781        .checked_add(denominator - 1)
2782        .ok_or(FormatError::WriterUnsupported("ceiling division overflow"))
2783        .map(|value| value / denominator)
2784}
2785
2786fn checked_u64_mul(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
2787    lhs.checked_mul(rhs)
2788        .ok_or(FormatError::WriterUnsupported(field))
2789}
2790
2791fn build_bootstrap_sidecar(
2792    subkeys: &Subkeys,
2793    archive_uuid: [u8; 16],
2794    session_id: [u8; 16],
2795    manifest_footer: &[u8; MANIFEST_FOOTER_LEN],
2796    index_root_records: &[BlockRecord],
2797    dictionary_records: Option<&[BlockRecord]>,
2798) -> Result<Vec<u8>, FormatError> {
2799    let index_records_len = index_root_records.iter().try_fold(0usize, |sum, record| {
2800        checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
2801    })?;
2802    let dictionary_records_len = dictionary_records
2803        .unwrap_or(&[])
2804        .iter()
2805        .try_fold(0usize, |sum, record| {
2806            checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
2807        })?;
2808    let manifest_offset = BOOTSTRAP_SIDECAR_HEADER_LEN as u64;
2809    let index_root_offset = manifest_offset + MANIFEST_FOOTER_LEN as u64;
2810    let dictionary_offset = if dictionary_records.is_some() {
2811        index_root_offset + index_records_len as u64
2812    } else {
2813        0
2814    };
2815    let mut header = BootstrapSidecarHeader {
2816        archive_uuid,
2817        session_id,
2818        flags: if dictionary_records.is_some() {
2819            0x07
2820        } else {
2821            0x03
2822        },
2823        manifest_footer_offset: manifest_offset,
2824        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
2825        index_root_records_offset: index_root_offset,
2826        index_root_records_length: index_records_len as u64,
2827        dictionary_records_offset: dictionary_offset,
2828        dictionary_records_length: dictionary_records_len as u64,
2829        sidecar_hmac: [0u8; 32],
2830        header_crc32c: 0,
2831    };
2832    let mut header_bytes = header.to_bytes();
2833    header.sidecar_hmac = compute_hmac(
2834        HmacDomain::BootstrapSidecar,
2835        &subkeys.mac_key,
2836        &archive_uuid,
2837        &session_id,
2838        &header_bytes[..92],
2839    );
2840    header_bytes = header.to_bytes();
2841
2842    let mut sidecar = Vec::with_capacity(
2843        BOOTSTRAP_SIDECAR_HEADER_LEN
2844            + MANIFEST_FOOTER_LEN
2845            + index_records_len
2846            + dictionary_records_len,
2847    );
2848    sidecar.extend_from_slice(&header_bytes);
2849    sidecar.extend_from_slice(manifest_footer);
2850    for record in index_root_records {
2851        sidecar.extend_from_slice(&record.to_bytes());
2852    }
2853    if let Some(dictionary_records) = dictionary_records {
2854        for record in dictionary_records {
2855            sidecar.extend_from_slice(&record.to_bytes());
2856        }
2857    }
2858    Ok(sidecar)
2859}
2860
2861fn build_regular_file_member_group(
2862    path: &[u8],
2863    contents: &[u8],
2864    mode: u32,
2865    mtime: u64,
2866) -> Result<Vec<u8>, FormatError> {
2867    let mut out = Vec::new();
2868    let header_path = if path_requires_pax(path) {
2869        let pax_payload = build_pax_record("path", path)?;
2870        let pax_header = build_ustar_header(
2871            b"PaxHeaders/path",
2872            pax_payload.len() as u64,
2873            0o644,
2874            mtime,
2875            b'x',
2876        )?;
2877        out.extend_from_slice(&pax_header);
2878        out.extend_from_slice(&pax_payload);
2879        out.resize(out.len() + padding_to_512(pax_payload.len()), 0);
2880        pax_ustar_fallback_path(path)
2881    } else {
2882        path.to_vec()
2883    };
2884
2885    let header = build_ustar_header(&header_path, contents.len() as u64, mode, mtime, b'0')?;
2886    out.extend_from_slice(&header);
2887    out.extend_from_slice(contents);
2888    out.resize(out.len() + padding_to_512(contents.len()), 0);
2889    Ok(out)
2890}
2891
2892fn path_requires_pax(path: &[u8]) -> bool {
2893    path.len() > 100 || !path.is_ascii()
2894}
2895
2896fn pax_ustar_fallback_path(path: &[u8]) -> Vec<u8> {
2897    path.rsplit(|byte| *byte == b'/')
2898        .next()
2899        .filter(|component| !component.is_empty() && component.len() <= 100 && component.is_ascii())
2900        .map(|component| component.to_vec())
2901        .unwrap_or_else(|| b"pax-file".to_vec())
2902}
2903
2904fn build_pax_record(key: &str, value: &[u8]) -> Result<Vec<u8>, FormatError> {
2905    let body_len = checked_usize_add(key.len(), 1, "PAX record")?;
2906    let body_len = checked_usize_add(body_len, value.len(), "PAX record")?;
2907    let body_len = checked_usize_add(body_len, 1, "PAX record")?;
2908    let mut digits = 1usize;
2909    loop {
2910        let len = checked_usize_add(digits, 1, "PAX record")?;
2911        let len = checked_usize_add(len, body_len, "PAX record")?;
2912        let next_digits = len.to_string().len();
2913        if next_digits == digits {
2914            let mut out = Vec::with_capacity(len);
2915            out.extend_from_slice(len.to_string().as_bytes());
2916            out.push(b' ');
2917            out.extend_from_slice(key.as_bytes());
2918            out.push(b'=');
2919            out.extend_from_slice(value);
2920            out.push(b'\n');
2921            return Ok(out);
2922        }
2923        digits = next_digits;
2924    }
2925}
2926
2927fn build_ustar_header(
2928    path: &[u8],
2929    size: u64,
2930    mode: u32,
2931    mtime: u64,
2932    typeflag: u8,
2933) -> Result<[u8; TAR_BLOCK_LEN], FormatError> {
2934    if path.len() > 100 {
2935        return Err(FormatError::WriterUnsupported(
2936            "ustar path exceeds name field",
2937        ));
2938    }
2939    let mut header = [0u8; TAR_BLOCK_LEN];
2940    header[0..path.len()].copy_from_slice(path);
2941    write_tar_octal(&mut header[100..108], mode as u64)?;
2942    write_tar_octal(&mut header[108..116], 0)?;
2943    write_tar_octal(&mut header[116..124], 0)?;
2944    write_tar_octal(&mut header[124..136], size)?;
2945    write_tar_octal(&mut header[136..148], mtime)?;
2946    header[148..156].fill(b' ');
2947    header[156] = typeflag;
2948    header[257..263].copy_from_slice(b"ustar\0");
2949    header[263..265].copy_from_slice(b"00");
2950    let checksum = header.iter().map(|byte| *byte as u32).sum::<u32>() as u64;
2951    write_tar_checksum(&mut header[148..156], checksum)?;
2952    Ok(header)
2953}
2954
2955fn write_tar_octal(field: &mut [u8], value: u64) -> Result<(), FormatError> {
2956    let digits = format!("{value:o}");
2957    if digits.len() + 1 > field.len() {
2958        return Err(FormatError::WriterUnsupported("tar octal field overflow"));
2959    }
2960    field.fill(0);
2961    let padding = field.len() - 1 - digits.len();
2962    for byte in &mut field[..padding] {
2963        *byte = b'0';
2964    }
2965    field[padding..padding + digits.len()].copy_from_slice(digits.as_bytes());
2966    Ok(())
2967}
2968
2969fn write_tar_checksum(field: &mut [u8], value: u64) -> Result<(), FormatError> {
2970    let digits = format!("{value:06o}");
2971    if digits.len() != 6 {
2972        return Err(FormatError::WriterUnsupported(
2973            "tar checksum field overflow",
2974        ));
2975    }
2976    field[0..6].copy_from_slice(digits.as_bytes());
2977    field[6] = 0;
2978    field[7] = b' ';
2979    Ok(())
2980}
2981
2982fn member_frame_range(
2983    member_index: usize,
2984    frames: &[PayloadFrame],
2985) -> Result<(u64, u32), FormatError> {
2986    let first = frames
2987        .iter()
2988        .find(|frame| frame.member_index == member_index)
2989        .map(|frame| frame.frame_index)
2990        .ok_or(FormatError::WriterInvariant("member frame is missing"))?;
2991    let count = frames
2992        .iter()
2993        .filter(|frame| frame.member_index == member_index)
2994        .count();
2995    Ok((first, u32_len(count, "FileEntry.frame_count")?))
2996}
2997
2998fn envelope_frame_range(
2999    envelope_index: u64,
3000    frames: &[PayloadFrame],
3001) -> Result<(u64, u32), FormatError> {
3002    let first = frames
3003        .iter()
3004        .find(|frame| frame.envelope_index == envelope_index)
3005        .map(|frame| frame.frame_index)
3006        .ok_or(FormatError::WriterInvariant("envelope frame is missing"))?;
3007    let count = frames
3008        .iter()
3009        .filter(|frame| frame.envelope_index == envelope_index)
3010        .count();
3011    Ok((first, u32_len(count, "EnvelopeEntry.frame_count")?))
3012}
3013
3014fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
3015    let digest = Sha256::digest(bytes);
3016    let mut out = [0u8; 32];
3017    out.copy_from_slice(&digest);
3018    out
3019}
3020
3021fn padding_to_512(len: usize) -> usize {
3022    let remainder = len % TAR_BLOCK_LEN;
3023    if remainder == 0 {
3024        0
3025    } else {
3026        TAR_BLOCK_LEN - remainder
3027    }
3028}
3029
3030fn table_offset(len: usize, cursor: usize) -> Result<u32, FormatError> {
3031    if len == 0 {
3032        Ok(0)
3033    } else {
3034        u32_len(cursor, "table offset")
3035    }
3036}
3037
3038fn u32_len(value: usize, field: &'static str) -> Result<u32, FormatError> {
3039    u32::try_from(value).map_err(|_| FormatError::WriterUnsupported(field))
3040}
3041
3042fn to_usize_writer(value: u64, field: &'static str) -> Result<usize, FormatError> {
3043    usize::try_from(value).map_err(|_| FormatError::WriterUnsupported(field))
3044}
3045
3046fn checked_usize_add(lhs: usize, rhs: usize, field: &'static str) -> Result<usize, FormatError> {
3047    lhs.checked_add(rhs)
3048        .ok_or(FormatError::WriterUnsupported(field))
3049}
3050
3051fn checked_u64_add(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
3052    lhs.checked_add(rhs)
3053        .ok_or(FormatError::WriterUnsupported(field))
3054}
3055
3056#[cfg(test)]
3057mod tests {
3058    use super::*;
3059    use crate::crypto::{verify_hmac, Subkeys};
3060    use crate::metadata::{DirectoryHintTable, MetadataLimits};
3061    use crate::reader::open_archive;
3062    use crate::tar_model::parse_tar_member_group;
3063    use crate::wire::{CriticalRecoveryLocator, CryptoHeader};
3064
3065    #[test]
3066    fn writer_defaults_use_v36_sizing_and_parallel_mode() {
3067        let options = WriterOptions::default();
3068
3069        assert_eq!(options.chunk_size, 256 * 1024);
3070        assert_eq!(options.envelope_target_size, 1024 * 1024);
3071        assert_eq!(options.block_size, 64 * 1024);
3072        assert_eq!(options.stripe_width, 8);
3073        assert_eq!(options.volume_loss_tolerance, 1);
3074        assert_eq!(options.fec_data_shards, 224);
3075        assert_eq!(options.index_fec_data_shards, 16);
3076        assert_eq!(
3077            options.index_root_fec_data_shards,
3078            MIN_INDEX_ROOT_FEC_DATA_SHARDS
3079        );
3080        assert_eq!(options.bit_rot_buffer_pct, 5);
3081    }
3082
3083    #[test]
3084    fn production_writer_defaults_generate_distinct_v4_identities() {
3085        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
3086        let first = write_archive(&[], &master_key, WriterOptions::default()).unwrap();
3087        let second = write_archive(&[], &master_key, WriterOptions::default()).unwrap();
3088
3089        assert_ne!(first.archive_uuid, [0u8; 16]);
3090        assert_ne!(first.session_id, [0u8; 16]);
3091        assert_ne!(second.archive_uuid, [0u8; 16]);
3092        assert_ne!(second.session_id, [0u8; 16]);
3093        assert_ne!(first.archive_uuid, first.session_id);
3094        assert_ne!(first.archive_uuid, second.archive_uuid);
3095        assert_ne!(first.session_id, second.session_id);
3096
3097        for raw in [
3098            first.archive_uuid,
3099            first.session_id,
3100            second.archive_uuid,
3101            second.session_id,
3102        ] {
3103            let id = Uuid::from_bytes(raw);
3104            assert_eq!(id.get_version_num(), 4);
3105        }
3106
3107        let deterministic = WriterOptions {
3108            archive_uuid: Some([0x44; 16]),
3109            session_id: Some([0x55; 16]),
3110            ..WriterOptions::default()
3111        };
3112        let fixture = write_archive(&[], &master_key, deterministic).unwrap();
3113        assert_eq!(fixture.archive_uuid, [0x44; 16]);
3114        assert_eq!(fixture.session_id, [0x55; 16]);
3115    }
3116
3117    #[test]
3118    fn writer_partitions_multiple_default_sized_index_shards() {
3119        let members = (0..=DEFAULT_FILES_PER_INDEX_SHARD)
3120            .map(|idx| TarMember {
3121                path: format!("file-{idx:05}.txt").into_bytes(),
3122                tar_member_group_start: idx as u64 * 512,
3123                tar_member_group_size: 512,
3124                file_data_size: 0,
3125            })
3126            .collect::<Vec<_>>();
3127
3128        let shards = partition_file_rows(sorted_file_rows(&members)).unwrap();
3129
3130        assert_eq!(shards.len(), 2);
3131        assert_eq!(shards[0].len(), DEFAULT_FILES_PER_INDEX_SHARD);
3132        assert_eq!(shards[1].len(), 1);
3133    }
3134
3135    #[test]
3136    fn writer_extends_shard_for_bounded_hash_prefix_run() {
3137        let mut rows = Vec::new();
3138        rows.extend((0..9_000).map(|idx| test_file_row(idx, [0u8; 8])));
3139        rows.extend((9_000..54_000).map(|idx| test_file_row(idx, [1u8; 8])));
3140        rows.push(test_file_row(54_000, [2u8; 8]));
3141
3142        let shards = partition_file_rows(rows).unwrap();
3143
3144        assert_eq!(shards.len(), 2);
3145        assert_eq!(shards[0].len(), 54_000);
3146        assert!(shards[0]
3147            .iter()
3148            .skip(9_000)
3149            .all(|row| row.path_hash == [1u8; 8]));
3150        assert_eq!(shards[1][0].path_hash, [2u8; 8]);
3151    }
3152
3153    #[test]
3154    fn writer_splits_oversized_hash_prefix_run_at_writer_ceiling() {
3155        let rows = (0..MAX_HASH_PREFIX_RUN_FILES + 1)
3156            .map(|idx| test_file_row(idx, [7u8; 8]))
3157            .collect::<Vec<_>>();
3158
3159        let shards = partition_file_rows(rows).unwrap();
3160
3161        assert_eq!(shards.len(), 2);
3162        assert_eq!(shards[0].len(), MAX_HASH_PREFIX_RUN_FILES);
3163        assert_eq!(shards[1].len(), 1);
3164    }
3165
3166    #[test]
3167    fn writer_builds_directory_hint_rows_for_ancestor_directories() {
3168        let shard_rows = vec![
3169            vec![FileRow {
3170                path_hash: hash_prefix(b"a/b/one.txt"),
3171                path: b"a/b/one.txt".to_vec(),
3172                member_index: 0,
3173                member: TarMember {
3174                    path: b"a/b/one.txt".to_vec(),
3175                    tar_member_group_start: 0,
3176                    tar_member_group_size: 512,
3177                    file_data_size: 0,
3178                },
3179            }],
3180            vec![FileRow {
3181                path_hash: hash_prefix(b"a/c/two.txt"),
3182                path: b"a/c/two.txt".to_vec(),
3183                member_index: 1,
3184                member: TarMember {
3185                    path: b"a/c/two.txt".to_vec(),
3186                    tar_member_group_start: 512,
3187                    tar_member_group_size: 512,
3188                    file_data_size: 0,
3189                },
3190            }],
3191        ];
3192
3193        let options = plan_writer_options(WriterOptions::default()).unwrap();
3194        let planned = build_directory_hint_plaintexts(&shard_rows, options).unwrap();
3195        assert_eq!(planned.len(), 1);
3196        let locating = DirectoryHintShardEntry {
3197            hint_shard_index: planned[0].hint_shard_index,
3198            first_dir_hash: planned[0].first_dir_hash,
3199            last_dir_hash: planned[0].last_dir_hash,
3200            first_block_index: 0,
3201            data_block_count: 1,
3202            parity_block_count: 0,
3203            encrypted_size: 4096,
3204            decompressed_size: planned[0].plaintext.len() as u32,
3205            entry_count: planned[0].entry_count,
3206        };
3207        let table = DirectoryHintTable::parse(
3208            &planned[0].plaintext,
3209            &locating,
3210            2,
3211            MetadataLimits::default(),
3212        )
3213        .unwrap();
3214
3215        let root = table.lookup_directory_index(b"").unwrap();
3216        assert_eq!(table.shard_rows_for_entry(root).unwrap(), &[0, 1]);
3217        let a = table.lookup_directory_index(b"a").unwrap();
3218        assert_eq!(table.shard_rows_for_entry(a).unwrap(), &[0, 1]);
3219        let ab = table.lookup_directory_index(b"a/b").unwrap();
3220        assert_eq!(table.shard_rows_for_entry(ab).unwrap(), &[0]);
3221    }
3222
3223    #[test]
3224    fn directory_hints_are_required_only_above_v36_threshold() {
3225        assert!(!should_emit_directory_hints(0));
3226        assert!(!should_emit_directory_hints(
3227            DIRECTORY_HINT_REQUIRED_FILE_COUNT
3228        ));
3229        assert!(should_emit_directory_hints(
3230            DIRECTORY_HINT_REQUIRED_FILE_COUNT + 1
3231        ));
3232    }
3233
3234    #[test]
3235    fn regular_file_writer_uses_local_pax_path_for_long_and_non_ascii_paths() {
3236        let long_path = format!("dir/{}.txt", "a".repeat(120));
3237        let unicode_path = "unicode/e\u{301}.txt";
3238        let files = [
3239            RegularFile::new(&long_path, b"long path"),
3240            RegularFile::new(unicode_path, b"unicode path"),
3241        ];
3242
3243        let (tar_stream, members) = build_tar_stream(&files, 4096).unwrap();
3244
3245        for (member, expected_path, expected_data) in [
3246            (&members[0], long_path.as_bytes(), b"long path".as_slice()),
3247            (
3248                &members[1],
3249                "unicode/\u{e9}.txt".as_bytes(),
3250                b"unicode path".as_slice(),
3251            ),
3252        ] {
3253            let start = member.tar_member_group_start as usize;
3254            let end = start + member.tar_member_group_size as usize;
3255            let group = &tar_stream[start..end];
3256            assert_eq!(group[156], b'x');
3257            let parsed = parse_tar_member_group(group, 4096).unwrap();
3258            assert_eq!(parsed.path, expected_path);
3259            assert_eq!(parsed.data, expected_data);
3260        }
3261    }
3262
3263    #[test]
3264    fn regular_file_writer_emits_no_global_metadata_or_tar_eof() {
3265        let long_path = format!("dir/{}.txt", "a".repeat(120));
3266        let files = [
3267            RegularFile::new("plain.txt", b"plain contents"),
3268            RegularFile::new(&long_path, b"long path contents"),
3269        ];
3270
3271        let (tar_stream, members) = build_tar_stream(&files, 4096).unwrap();
3272
3273        let member_bytes = members
3274            .iter()
3275            .map(|member| member.tar_member_group_size)
3276            .sum::<u64>();
3277        assert_eq!(tar_stream.len() as u64, member_bytes);
3278        assert!(!tar_stream[tar_stream.len() - TAR_BLOCK_LEN * 2..]
3279            .chunks(TAR_BLOCK_LEN)
3280            .all(|block| block.iter().all(|byte| *byte == 0)));
3281
3282        for member in members {
3283            let start = member.tar_member_group_start as usize;
3284            let end = start + member.tar_member_group_size as usize;
3285            assert_path_specific_member_group(&tar_stream[start..end]);
3286        }
3287    }
3288
3289    #[test]
3290    fn regular_file_writer_round_trips_mode_and_mtime() {
3291        let group =
3292            build_regular_file_member_group(b"script.sh", b"#!/bin/sh\n", 0o755, 1_700_000_000)
3293                .unwrap();
3294
3295        let parsed = parse_tar_member_group(&group, 4096).unwrap();
3296
3297        assert_eq!(parsed.mode, 0o755);
3298        assert_eq!(parsed.mtime, 1_700_000_000);
3299    }
3300
3301    #[test]
3302    fn writer_splits_large_payload_across_seekable_envelopes() {
3303        let master_key = MasterKey::from_raw_key(&[8u8; 32]).unwrap();
3304        let data = deterministic_bytes(2 * 1024 * 1024);
3305        let archive = write_archive(
3306            &[RegularFile::new("large.bin", &data)],
3307            &master_key,
3308            WriterOptions {
3309                stripe_width: 1,
3310                volume_loss_tolerance: 0,
3311                bit_rot_buffer_pct: 0,
3312                ..WriterOptions::default()
3313            },
3314        )
3315        .unwrap();
3316        let opened = open_archive(&archive.bytes, &master_key).unwrap();
3317
3318        assert_eq!(opened.list_files().unwrap()[0].path, "large.bin");
3319        assert_eq!(opened.extract_file("large.bin").unwrap(), Some(data));
3320        opened.verify().unwrap();
3321        assert!(opened.index_root.header.envelope_count > 1);
3322    }
3323
3324    #[test]
3325    fn split_member_frames_carry_exact_boundary_flags() {
3326        let data = deterministic_bytes(12 * 1024);
3327        let files = [RegularFile::new("large.bin", &data)];
3328        let options = WriterOptions {
3329            chunk_size: 1024,
3330            envelope_target_size: 64 * 1024,
3331            stripe_width: 1,
3332            volume_loss_tolerance: 0,
3333            bit_rot_buffer_pct: 0,
3334            ..WriterOptions::default()
3335        };
3336        let (tar_stream, members) = build_tar_stream(&files, options.max_path_length).unwrap();
3337        let (_, frames) = build_payload_envelopes(&tar_stream, &members, options, None).unwrap();
3338
3339        assert!(frames.len() > 2);
3340        assert_eq!(frames.first().unwrap().flags, 0x0000_0001);
3341        assert_eq!(frames.last().unwrap().flags, 0x0000_0002);
3342        assert!(frames[1..frames.len() - 1]
3343            .iter()
3344            .all(|frame| frame.flags == 0));
3345    }
3346
3347    #[test]
3348    fn writes_empty_archive_with_authentic_bootstrap_structures() {
3349        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
3350        let archive = write_empty_archive(&master_key).unwrap();
3351        let bytes = archive.bytes;
3352
3353        let volume_header = VolumeHeader::parse(&bytes[..VOLUME_HEADER_LEN]).unwrap();
3354        assert_eq!(volume_header.archive_uuid, archive.archive_uuid);
3355        assert_eq!(volume_header.session_id, archive.session_id);
3356
3357        let crypto_start = VOLUME_HEADER_LEN;
3358        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
3359        let crypto_header = CryptoHeader::parse(
3360            &bytes[crypto_start..crypto_end],
3361            volume_header.crypto_header_length,
3362        )
3363        .unwrap();
3364        let subkeys =
3365            Subkeys::derive(&master_key, &archive.archive_uuid, &archive.session_id).unwrap();
3366        verify_hmac(
3367            HmacDomain::CryptoHeader,
3368            &subkeys.mac_key,
3369            &archive.archive_uuid,
3370            &archive.session_id,
3371            crypto_header.hmac_covered_bytes,
3372            &crypto_header.header_hmac,
3373        )
3374        .unwrap();
3375
3376        let locator =
3377            CriticalRecoveryLocator::parse(&bytes[bytes.len() - CRITICAL_RECOVERY_LOCATOR_LEN..])
3378                .unwrap();
3379        let trailer_offset = locator.volume_trailer_offset as usize;
3380        let trailer =
3381            VolumeTrailer::parse(&bytes[trailer_offset..trailer_offset + VOLUME_TRAILER_LEN])
3382                .unwrap();
3383        assert_eq!(trailer.bytes_written, trailer_offset as u64);
3384        verify_hmac(
3385            HmacDomain::VolumeTrailer,
3386            &subkeys.mac_key,
3387            &archive.archive_uuid,
3388            &archive.session_id,
3389            &bytes[trailer_offset..trailer_offset + 96],
3390            &trailer.trailer_hmac,
3391        )
3392        .unwrap();
3393
3394        let manifest_offset = trailer.manifest_footer_offset as usize;
3395        let manifest_end = manifest_offset + MANIFEST_FOOTER_LEN;
3396        let manifest = ManifestFooter::parse(&bytes[manifest_offset..manifest_end]).unwrap();
3397        assert_eq!(manifest.is_authoritative, 1);
3398        assert_eq!(manifest.total_volumes, DEFAULT_STRIPE_WIDTH);
3399        verify_hmac(
3400            HmacDomain::ManifestFooter,
3401            &subkeys.mac_key,
3402            &archive.archive_uuid,
3403            &archive.session_id,
3404            &bytes[manifest_offset..manifest_offset + 104],
3405            &manifest.manifest_hmac,
3406        )
3407        .unwrap();
3408    }
3409
3410    #[test]
3411    fn parity_auto_scaling_matches_v36_examples() {
3412        let options = WriterOptions {
3413            fec_data_shards: 224,
3414            stripe_width: 8,
3415            volume_loss_tolerance: 1,
3416            bit_rot_buffer_pct: 5,
3417            ..WriterOptions::default()
3418        };
3419
3420        assert_eq!(compute_parity(224, options).unwrap(), 48);
3421        assert_eq!(compute_parity(17, options).unwrap(), 5);
3422    }
3423
3424    #[test]
3425    fn parity_auto_scaling_rejects_non_convergent_budget() {
3426        let err = compute_parity(
3427            1,
3428            WriterOptions {
3429                stripe_width: 2,
3430                volume_loss_tolerance: 1,
3431                bit_rot_buffer_pct: 50,
3432                ..WriterOptions::default()
3433            },
3434        )
3435        .unwrap_err();
3436
3437        assert_eq!(
3438            err,
3439            FormatError::WriterUnsupported("parity calculation did not converge")
3440        );
3441    }
3442
3443    #[test]
3444    fn zero_parity_is_allowed_when_no_recovery_margin_is_requested() {
3445        let planned = plan_writer_options(WriterOptions {
3446            bit_rot_buffer_pct: 0,
3447            stripe_width: 1,
3448            volume_loss_tolerance: 0,
3449            fec_parity_shards: 0,
3450            index_fec_parity_shards: 0,
3451            index_root_fec_parity_shards: 0,
3452            ..WriterOptions::default()
3453        })
3454        .unwrap();
3455
3456        assert_eq!(planned.fec_parity_shards, 0);
3457        assert_eq!(planned.index_fec_parity_shards, 0);
3458        assert_eq!(planned.index_root_fec_parity_shards, 0);
3459        assert_eq!(compute_parity(1, planned).unwrap(), 0);
3460    }
3461
3462    #[test]
3463    fn index_root_data_shard_maximum_obeys_v36_minimum() {
3464        let planned = plan_writer_options(WriterOptions {
3465            index_root_fec_data_shards: 1,
3466            ..WriterOptions::default()
3467        })
3468        .unwrap();
3469
3470        assert_eq!(
3471            planned.index_root_fec_data_shards,
3472            MIN_INDEX_ROOT_FEC_DATA_SHARDS
3473        );
3474    }
3475
3476    #[test]
3477    fn metadata_class_planning_raises_index_root_class_above_default() {
3478        let options = plan_writer_options(WriterOptions {
3479            block_size: MIN_BLOCK_SIZE,
3480            index_root_fec_parity_shards: 0,
3481            bit_rot_buffer_pct: 0,
3482            ..WriterOptions::default()
3483        })
3484        .unwrap();
3485        let index_root_payload_len = payload_len_for_encrypted_data_blocks(64, options);
3486
3487        let planned =
3488            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap();
3489
3490        assert_eq!(planned.index_root.data_block_count, 64);
3491        assert_eq!(planned.options.index_root_fec_data_shards, 64);
3492        assert_eq!(
3493            planned.options.index_root_fec_parity_shards,
3494            compute_parity_u16(
3495                planned.options.index_root_fec_data_shards as u64,
3496                planned.options,
3497                "index_root_fec_parity_shards",
3498            )
3499            .unwrap()
3500        );
3501    }
3502
3503    #[test]
3504    fn metadata_class_planning_rejects_oversized_index_root() {
3505        let options = single_volume_metadata_test_options();
3506        let index_root_payload_len =
3507            payload_len_for_encrypted_data_blocks(u16::MAX as u32 + 1, options);
3508
3509        let err =
3510            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap_err();
3511
3512        assert_eq!(err, FormatError::WriterUnsupported("IndexRoot too large"));
3513    }
3514
3515    #[test]
3516    fn metadata_class_planning_rejects_index_root_u32_encrypted_size_overflow() {
3517        let options = single_volume_metadata_test_options();
3518        let index_root_payload_len = u32::MAX as usize - options.aead_algo.tag_len() + 1;
3519
3520        let err =
3521            plan_index_root_metadata_class(options, index_root_payload_len, None).unwrap_err();
3522
3523        assert_eq!(err, FormatError::WriterUnsupported("IndexRoot too large"));
3524    }
3525
3526    #[test]
3527    fn metadata_class_planning_rejects_oversized_dictionary() {
3528        let options = single_volume_metadata_test_options();
3529        let dictionary_payload_len =
3530            payload_len_for_encrypted_data_blocks(u16::MAX as u32 + 1, options);
3531
3532        let err =
3533            plan_index_root_metadata_class(options, 1, Some(dictionary_payload_len)).unwrap_err();
3534
3535        assert_eq!(
3536            err,
3537            FormatError::WriterUnsupported("dictionary object too large")
3538        );
3539    }
3540
3541    #[test]
3542    fn metadata_class_planning_rejects_gf16_total_overflow_for_dictionary() {
3543        let options = plan_writer_options(WriterOptions {
3544            block_size: MIN_BLOCK_SIZE,
3545            stripe_width: 8,
3546            volume_loss_tolerance: 1,
3547            bit_rot_buffer_pct: 5,
3548            ..WriterOptions::default()
3549        })
3550        .unwrap();
3551        let dictionary_payload_len = payload_len_for_encrypted_data_blocks(60_000, options);
3552
3553        let err =
3554            plan_index_root_metadata_class(options, 1, Some(dictionary_payload_len)).unwrap_err();
3555
3556        assert_eq!(
3557            err,
3558            FormatError::WriterUnsupported("dictionary object too large")
3559        );
3560    }
3561
3562    #[test]
3563    fn written_archive_authenticates_final_index_root_fec_class() {
3564        let master_key = MasterKey::from_raw_key(&[9u8; 32]).unwrap();
3565        let dictionary = deterministic_bytes(80 * 1024);
3566        let file = RegularFile::new("uses-dictionary.txt", b"payload");
3567        let archive = write_archive_with_dictionary(
3568            &[file],
3569            &master_key,
3570            WriterOptions {
3571                block_size: MIN_BLOCK_SIZE,
3572                stripe_width: 1,
3573                volume_loss_tolerance: 0,
3574                bit_rot_buffer_pct: 0,
3575                index_root_fec_parity_shards: 0,
3576                ..WriterOptions::default()
3577            },
3578            &dictionary,
3579        )
3580        .unwrap();
3581
3582        let volume_header = VolumeHeader::parse(&archive.bytes[..VOLUME_HEADER_LEN]).unwrap();
3583        let crypto_start = VOLUME_HEADER_LEN;
3584        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
3585        let crypto_header = CryptoHeader::parse(
3586            &archive.bytes[crypto_start..crypto_end],
3587            volume_header.crypto_header_length,
3588        )
3589        .unwrap();
3590        let subkeys =
3591            Subkeys::derive(&master_key, &archive.archive_uuid, &archive.session_id).unwrap();
3592        verify_hmac(
3593            HmacDomain::CryptoHeader,
3594            &subkeys.mac_key,
3595            &archive.archive_uuid,
3596            &archive.session_id,
3597            crypto_header.hmac_covered_bytes,
3598            &crypto_header.header_hmac,
3599        )
3600        .unwrap();
3601
3602        assert!(crypto_header.fixed.index_root_fec_data_shards > MIN_INDEX_ROOT_FEC_DATA_SHARDS);
3603        assert_eq!(crypto_header.fixed.index_root_fec_parity_shards, 0);
3604        let opened = open_archive(&archive.bytes, &master_key).unwrap();
3605        assert_eq!(
3606            opened.extract_file("uses-dictionary.txt").unwrap(),
3607            Some(b"payload".to_vec())
3608        );
3609        opened.verify().unwrap();
3610    }
3611
3612    #[test]
3613    fn object_parity_uses_per_object_recurrence_even_with_larger_class_max() {
3614        let options = WriterOptions {
3615            bit_rot_buffer_pct: 0,
3616            stripe_width: 1,
3617            volume_loss_tolerance: 0,
3618            fec_parity_shards: 1,
3619            ..WriterOptions::default()
3620        };
3621
3622        assert_eq!(compute_object_parity(1, options, 1).unwrap(), 0);
3623    }
3624
3625    #[test]
3626    fn object_total_shards_obeys_reed_solomon_limit() {
3627        assert!(validate_object_shard_total(65_535, 0).is_ok());
3628        assert_eq!(
3629            validate_object_shard_total(65_535, 1).unwrap_err(),
3630            FormatError::WriterUnsupported("encrypted object exceeds ReedSolomonGF16 shard limit")
3631        );
3632    }
3633
3634    #[test]
3635    fn argon2id_kdf_serialization_rejects_memory_requirement_overflow() {
3636        assert_eq!(
3637            serialize_kdf_params(&KdfParams::Argon2id {
3638                t_cost: 1,
3639                m_cost_kib: u32::MAX,
3640                parallelism: u32::MAX,
3641                salt: b"12345678".to_vec(),
3642            })
3643            .unwrap_err(),
3644            FormatError::InvalidKdfParams("m_cost_kib requirement overflow")
3645        );
3646    }
3647
3648    fn deterministic_bytes(len: usize) -> Vec<u8> {
3649        let mut state = 0x4d41_4d45u32;
3650        let mut out = Vec::with_capacity(len);
3651        for _ in 0..len {
3652            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
3653            out.push((state >> 24) as u8);
3654        }
3655        out
3656    }
3657
3658    fn single_volume_metadata_test_options() -> WriterOptions {
3659        plan_writer_options(WriterOptions {
3660            block_size: MIN_BLOCK_SIZE,
3661            stripe_width: 1,
3662            volume_loss_tolerance: 0,
3663            bit_rot_buffer_pct: 0,
3664            index_root_fec_parity_shards: 0,
3665            ..WriterOptions::default()
3666        })
3667        .unwrap()
3668    }
3669
3670    fn payload_len_for_encrypted_data_blocks(
3671        data_block_count: u32,
3672        options: WriterOptions,
3673    ) -> usize {
3674        assert!(data_block_count > 0);
3675        if data_block_count == 1 {
3676            return 1;
3677        }
3678        let block_size = options.block_size as usize;
3679        (data_block_count as usize - 1) * block_size - options.aead_algo.tag_len() + 1
3680    }
3681
3682    fn assert_path_specific_member_group(group: &[u8]) {
3683        let mut cursor = 0usize;
3684        let mut saw_main = false;
3685        while cursor < group.len() {
3686            let header = &group[cursor..cursor + TAR_BLOCK_LEN];
3687            assert!(
3688                header.iter().any(|byte| *byte != 0),
3689                "writer emitted tar zero block inside member group"
3690            );
3691            let typeflag = header[156];
3692            assert_ne!(typeflag, b'g', "writer emitted global PAX metadata");
3693            assert!(
3694                !matches!(typeflag, b'V' | b'M' | b'N'),
3695                "writer emitted global GNU metadata"
3696            );
3697            assert!(
3698                matches!(typeflag, b'x' | b'0'),
3699                "writer emitted unexpected tar record type {typeflag:?}"
3700            );
3701            if typeflag == b'0' {
3702                saw_main = true;
3703            }
3704            let size = read_test_tar_octal(&header[124..136]);
3705            cursor += TAR_BLOCK_LEN + size + padding_to_512(size);
3706        }
3707        assert_eq!(cursor, group.len());
3708        assert!(saw_main);
3709    }
3710
3711    fn read_test_tar_octal(field: &[u8]) -> usize {
3712        let mut value = 0usize;
3713        for byte in field {
3714            match *byte {
3715                0 | b' ' => break,
3716                b'0'..=b'7' => {
3717                    value = value * 8 + usize::from(*byte - b'0');
3718                }
3719                other => panic!("malformed test tar octal byte {other:?}"),
3720            }
3721        }
3722        value
3723    }
3724
3725    fn test_file_row(idx: usize, path_hash: [u8; 8]) -> FileRow {
3726        let path = format!("file-{idx:05}.txt").into_bytes();
3727        FileRow {
3728            path_hash,
3729            path: path.clone(),
3730            member_index: idx,
3731            member: TarMember {
3732                path,
3733                tar_member_group_start: idx as u64 * 512,
3734                tar_member_group_size: 512,
3735                file_data_size: 0,
3736            },
3737        }
3738    }
3739}