Skip to main content

tzap_core/
writer.rs

1use sha2::{Digest, Sha256};
2use uuid::Uuid;
3
4use crate::compression::{compress_zstd_frame, compress_zstd_frame_with_dictionary};
5use crate::crypto::{
6    aead_encrypt, build_aad, compute_hmac, derive_nonce, HmacDomain, KdfParams, MasterKey, Subkeys,
7};
8use crate::fec::encode_parity_gf16;
9use crate::format::{
10    AeadAlgo, BlockKind, CompressionAlgo, FecAlgo, FormatError, KdfAlgo, BLOCK_RECORD_FRAMING_LEN,
11    BOOTSTRAP_SIDECAR_HEADER_LEN, CRYPTO_EXTENSION_HEADER_LEN, CRYPTO_HEADER_FIXED_LEN,
12    CRYPTO_HEADER_HMAC_LEN, FORMAT_VERSION, MANIFEST_FOOTER_LEN, VOLUME_FORMAT_REV,
13    VOLUME_HEADER_LEN, VOLUME_TRAILER_LEN,
14};
15use crate::metadata::{
16    hash_prefix, normalize_lookup_file_path, EnvelopeEntry, FileEntry, FrameEntry, IndexRoot,
17    IndexRootHeader, IndexShardHeader, ShardEntry, ENVELOPE_ENTRY_LEN, FILE_ENTRY_LEN,
18    FRAME_ENTRY_LEN, INDEX_SHARD_HEADER_LEN,
19};
20use crate::padding::suffix_pad_for_aead;
21use crate::wire::{
22    BlockRecord, BootstrapSidecarHeader, CryptoHeaderFixed, ManifestFooter, VolumeHeader,
23    VolumeTrailer,
24};
25
26const TAR_BLOCK_LEN: usize = 512;
27const MAX_REED_SOLOMON_GF16_SHARDS: u64 = 65_535;
28const MIN_BLOCK_SIZE: u32 = 4096;
29const DEFAULT_BLOCK_SIZE: u32 = 64 * 1024;
30const DEFAULT_CHUNK_SIZE: u32 = 256 * 1024;
31const DEFAULT_ENVELOPE_TARGET_SIZE: u32 = 1024 * 1024;
32const DEFAULT_FEC_DATA_SHARDS: u16 = 224;
33const DEFAULT_FEC_PARITY_SHARDS: u16 = 1;
34const DEFAULT_INDEX_FEC_DATA_SHARDS: u16 = 16;
35const DEFAULT_INDEX_FEC_PARITY_SHARDS: u16 = 1;
36const MIN_INDEX_ROOT_FEC_DATA_SHARDS: u16 = 16;
37const DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS: u16 = MIN_INDEX_ROOT_FEC_DATA_SHARDS;
38const DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS: u16 = 1;
39const DEFAULT_STRIPE_WIDTH: u32 = 8;
40const DEFAULT_VOLUME_LOSS_TOLERANCE: u8 = 1;
41const DEFAULT_BIT_ROT_BUFFER_PCT: u8 = 5;
42const DEFAULT_FILES_PER_INDEX_SHARD: usize = 10_000;
43const DIRECTORY_HINT_REQUIRED_FILE_COUNT: usize = 100_000;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct WriterOptions {
47    pub block_size: u32,
48    pub chunk_size: u32,
49    pub envelope_target_size: u32,
50    pub stripe_width: u32,
51    pub volume_loss_tolerance: u8,
52    pub bit_rot_buffer_pct: u8,
53    pub zstd_level: i32,
54    pub aead_algo: AeadAlgo,
55    pub fec_data_shards: u16,
56    pub fec_parity_shards: u16,
57    pub index_fec_data_shards: u16,
58    pub index_fec_parity_shards: u16,
59    pub index_root_fec_data_shards: u16,
60    pub index_root_fec_parity_shards: u16,
61    pub max_path_length: u32,
62    pub target_volume_size: Option<u64>,
63    pub archive_uuid: Option<[u8; 16]>,
64    pub session_id: Option<[u8; 16]>,
65    pub closed_at_ns: i64,
66}
67
68impl Default for WriterOptions {
69    fn default() -> Self {
70        Self {
71            block_size: DEFAULT_BLOCK_SIZE,
72            chunk_size: DEFAULT_CHUNK_SIZE,
73            envelope_target_size: DEFAULT_ENVELOPE_TARGET_SIZE,
74            stripe_width: DEFAULT_STRIPE_WIDTH,
75            volume_loss_tolerance: DEFAULT_VOLUME_LOSS_TOLERANCE,
76            bit_rot_buffer_pct: DEFAULT_BIT_ROT_BUFFER_PCT,
77            zstd_level: 3,
78            aead_algo: AeadAlgo::AesGcmSiv256,
79            fec_data_shards: DEFAULT_FEC_DATA_SHARDS,
80            fec_parity_shards: DEFAULT_FEC_PARITY_SHARDS,
81            index_fec_data_shards: DEFAULT_INDEX_FEC_DATA_SHARDS,
82            index_fec_parity_shards: DEFAULT_INDEX_FEC_PARITY_SHARDS,
83            index_root_fec_data_shards: DEFAULT_INDEX_ROOT_FEC_DATA_SHARDS,
84            index_root_fec_parity_shards: DEFAULT_INDEX_ROOT_FEC_PARITY_SHARDS,
85            max_path_length: 4096,
86            target_volume_size: None,
87            archive_uuid: None,
88            session_id: None,
89            closed_at_ns: 0,
90        }
91    }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct RegularFile<'a> {
96    pub path: &'a str,
97    pub contents: &'a [u8],
98    pub mode: u32,
99    pub mtime: u64,
100}
101
102impl<'a> RegularFile<'a> {
103    pub fn new(path: &'a str, contents: &'a [u8]) -> Self {
104        Self {
105            path,
106            contents,
107            mode: 0o644,
108            mtime: 0,
109        }
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct WrittenArchive {
115    pub bytes: Vec<u8>,
116    pub volumes: Vec<Vec<u8>>,
117    pub bootstrap_sidecar: Vec<u8>,
118    pub archive_uuid: [u8; 16],
119    pub session_id: [u8; 16],
120}
121
122#[derive(Debug, Clone)]
123struct TarMember {
124    path: Vec<u8>,
125    tar_member_group_start: u64,
126    tar_member_group_size: u64,
127    file_data_size: u64,
128}
129
130#[derive(Debug, Clone)]
131struct PayloadFrame {
132    frame_index: u64,
133    offset_in_envelope: u32,
134    compressed_size: u32,
135    decompressed_size: u32,
136    tar_stream_offset: u64,
137}
138
139#[derive(Debug, Clone)]
140struct EncryptedObject {
141    first_block_index: u64,
142    data_block_count: u32,
143    parity_block_count: u32,
144    encrypted_size: u32,
145    records: Vec<BlockRecord>,
146}
147
148pub fn write_archive(
149    files: &[RegularFile<'_>],
150    master_key: &MasterKey,
151    options: WriterOptions,
152) -> Result<WrittenArchive, FormatError> {
153    write_archive_inner(files, master_key, options, None, &KdfParams::Raw)
154}
155
156pub fn write_archive_with_kdf(
157    files: &[RegularFile<'_>],
158    master_key: &MasterKey,
159    options: WriterOptions,
160    kdf_params: &KdfParams,
161) -> Result<WrittenArchive, FormatError> {
162    write_archive_inner(files, master_key, options, None, kdf_params)
163}
164
165pub fn write_archive_with_dictionary(
166    files: &[RegularFile<'_>],
167    master_key: &MasterKey,
168    options: WriterOptions,
169    dictionary: &[u8],
170) -> Result<WrittenArchive, FormatError> {
171    if dictionary.is_empty() {
172        return Err(FormatError::WriterUnsupported(
173            "dictionary archives require a non-empty dictionary",
174        ));
175    }
176    if files.is_empty() {
177        return Err(FormatError::WriterUnsupported(
178            "dictionary archives require at least one file",
179        ));
180    }
181    if dictionary.len() > u32::MAX as usize {
182        return Err(FormatError::WriterUnsupported(
183            "dictionary decompressed size exceeds u32",
184        ));
185    }
186    write_archive_inner(
187        files,
188        master_key,
189        options,
190        Some(dictionary),
191        &KdfParams::Raw,
192    )
193}
194
195pub fn write_archive_with_dictionary_and_kdf(
196    files: &[RegularFile<'_>],
197    master_key: &MasterKey,
198    options: WriterOptions,
199    dictionary: &[u8],
200    kdf_params: &KdfParams,
201) -> Result<WrittenArchive, FormatError> {
202    if dictionary.is_empty() {
203        return Err(FormatError::WriterUnsupported(
204            "dictionary archives require a non-empty dictionary",
205        ));
206    }
207    if files.is_empty() {
208        return Err(FormatError::WriterUnsupported(
209            "dictionary archives require at least one file",
210        ));
211    }
212    if dictionary.len() > u32::MAX as usize {
213        return Err(FormatError::WriterUnsupported(
214            "dictionary decompressed size exceeds u32",
215        ));
216    }
217    write_archive_inner(files, master_key, options, Some(dictionary), kdf_params)
218}
219
220fn write_archive_inner(
221    files: &[RegularFile<'_>],
222    master_key: &MasterKey,
223    options: WriterOptions,
224    dictionary: Option<&[u8]>,
225    kdf_params: &KdfParams,
226) -> Result<WrittenArchive, FormatError> {
227    let mut requested_options = options;
228    if requested_options.target_volume_size.is_some() {
229        requested_options.stripe_width = requested_options
230            .stripe_width
231            .max(requested_options.volume_loss_tolerance as u32 + 1);
232    }
233    validate_m6_file_scope(files.len())?;
234
235    let archive_uuid = requested_options
236        .archive_uuid
237        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
238    let session_id = requested_options
239        .session_id
240        .unwrap_or_else(|| *Uuid::new_v4().as_bytes());
241    loop {
242        let planned_options = plan_writer_options(requested_options)?;
243        let archive = write_archive_once(
244            files,
245            master_key,
246            planned_options,
247            dictionary,
248            kdf_params,
249            archive_uuid,
250            session_id,
251        )?;
252
253        let Some(target_volume_size) = planned_options.target_volume_size else {
254            return Ok(archive);
255        };
256        let required_stripe_width =
257            required_stripe_width_for_target(&archive, planned_options, target_volume_size)?;
258        if required_stripe_width <= planned_options.stripe_width {
259            return Ok(archive);
260        }
261        requested_options.stripe_width = required_stripe_width;
262    }
263}
264
265fn write_archive_once(
266    files: &[RegularFile<'_>],
267    master_key: &MasterKey,
268    options: WriterOptions,
269    dictionary: Option<&[u8]>,
270    kdf_params: &KdfParams,
271    archive_uuid: [u8; 16],
272    session_id: [u8; 16],
273) -> Result<WrittenArchive, FormatError> {
274    let subkeys = Subkeys::derive(master_key, &archive_uuid, &session_id)?;
275    let crypto_header = build_crypto_header(
276        options,
277        dictionary.is_some(),
278        &subkeys,
279        &archive_uuid,
280        &session_id,
281        kdf_params,
282    )?;
283
284    let mut next_block_index = 0u64;
285    let mut block_records = Vec::new();
286    let (tar_stream, tar_members) = build_tar_stream(files, options.max_path_length)?;
287    let tar_total_size = tar_stream.len() as u64;
288    let content_sha256 = sha256_bytes(&tar_stream);
289
290    let (payload_extent, frames, payload_block_count) = if tar_stream.is_empty() {
291        (None, Vec::new(), 0u64)
292    } else {
293        let (payload_plaintext, frames) =
294            build_payload_envelope(&tar_stream, &tar_members, options, dictionary)?;
295        let object = encrypt_object(
296            &payload_plaintext,
297            &subkeys.enc_key,
298            &subkeys.nonce_seed,
299            b"envelope",
300            0,
301            BlockKind::PayloadData,
302            BlockKind::PayloadParity,
303            options.fec_data_shards,
304            options.fec_parity_shards,
305            &mut next_block_index,
306            options,
307            &archive_uuid,
308            &session_id,
309        )?;
310        let payload_block_count = object.data_block_count as u64;
311        block_records.extend(object.records.clone());
312        (Some(object), frames, payload_block_count)
313    };
314
315    let (index_shard_extent, shard_entries, frame_count, envelope_count) = if tar_members.is_empty()
316    {
317        (None, Vec::new(), 0u64, 0u64)
318    } else {
319        let payload = payload_extent
320            .as_ref()
321            .ok_or(FormatError::WriterInvariant("payload extent missing"))?;
322        let index_shard_plaintext =
323            build_single_index_shard(&tar_members, &frames, payload, options)?;
324        let compressed = compress_zstd_frame(&index_shard_plaintext, options.zstd_level)?;
325        let object = encrypt_object(
326            &compressed,
327            &subkeys.index_shard_key,
328            &subkeys.index_nonce_seed,
329            b"idxshard",
330            0,
331            BlockKind::IndexShardData,
332            BlockKind::IndexShardParity,
333            options.index_fec_data_shards,
334            options.index_fec_parity_shards,
335            &mut next_block_index,
336            options,
337            &archive_uuid,
338            &session_id,
339        )?;
340        let (first_hash, last_hash) = file_hash_bounds(&tar_members)?;
341        let shard_entry = ShardEntry {
342            shard_index: 0,
343            first_block_index: object.first_block_index,
344            data_block_count: object.data_block_count,
345            parity_block_count: object.parity_block_count,
346            encrypted_size: object.encrypted_size,
347            decompressed_size: u32_len(index_shard_plaintext.len(), "IndexShard")?,
348            file_count: u32_len(tar_members.len(), "IndexShard.file_count")?,
349            first_path_hash: first_hash,
350            last_path_hash: last_hash,
351        };
352        block_records.extend(object.records.clone());
353        (Some(object), vec![shard_entry], frames.len() as u64, 1u64)
354    };
355
356    let dictionary_extent = if let Some(dictionary) = dictionary {
357        let compressed_dictionary = compress_zstd_frame(dictionary, options.zstd_level)?;
358        let object = encrypt_object(
359            &compressed_dictionary,
360            &subkeys.dictionary_key,
361            &subkeys.index_nonce_seed,
362            b"dict",
363            0,
364            BlockKind::DictionaryData,
365            BlockKind::DictionaryParity,
366            options.index_root_fec_data_shards,
367            options.index_root_fec_parity_shards,
368            &mut next_block_index,
369            options,
370            &archive_uuid,
371            &session_id,
372        )?;
373        block_records.extend(object.records.clone());
374        Some((object, dictionary.len() as u32))
375    } else {
376        None
377    };
378
379    let index_root_plaintext = build_index_root_plaintext(
380        &shard_entries,
381        frame_count,
382        envelope_count,
383        tar_members.len() as u64,
384        payload_block_count,
385        tar_total_size,
386        content_sha256,
387        dictionary_extent
388            .as_ref()
389            .map(|(object, decompressed_size)| (object, *decompressed_size)),
390    );
391    let compressed_index_root = compress_zstd_frame(&index_root_plaintext, options.zstd_level)?;
392    let index_root_extent = encrypt_object(
393        &compressed_index_root,
394        &subkeys.index_root_key,
395        &subkeys.index_nonce_seed,
396        b"idxroot",
397        0,
398        BlockKind::IndexRootData,
399        BlockKind::IndexRootParity,
400        options.index_root_fec_data_shards,
401        options.index_root_fec_parity_shards,
402        &mut next_block_index,
403        options,
404        &archive_uuid,
405        &session_id,
406    )?;
407    block_records.extend(index_root_extent.records.clone());
408
409    let stripe_width = options.stripe_width as usize;
410    let mut striped_records = vec![Vec::<BlockRecord>::new(); stripe_width];
411    for record in &block_records {
412        let volume_index = (record.block_index % options.stripe_width as u64) as usize;
413        striped_records[volume_index].push(record.clone());
414    }
415
416    let mut volumes = Vec::with_capacity(stripe_width);
417    let mut volume_zero_manifest = [0u8; MANIFEST_FOOTER_LEN];
418    for (volume_index, records) in striped_records.iter().enumerate() {
419        let volume_index = u32::try_from(volume_index)
420            .map_err(|_| FormatError::WriterUnsupported("volume_index"))?;
421        let volume_header = VolumeHeader {
422            format_version: FORMAT_VERSION,
423            volume_format_rev: VOLUME_FORMAT_REV,
424            volume_index,
425            stripe_width: options.stripe_width,
426            archive_uuid,
427            session_id,
428            crypto_header_offset: VOLUME_HEADER_LEN as u32,
429            crypto_header_length: u32_len(crypto_header.len(), "CryptoHeader")?,
430            header_crc32c: 0,
431        };
432
433        let mut bytes = Vec::new();
434        bytes.extend_from_slice(&volume_header.to_bytes());
435        bytes.extend_from_slice(&crypto_header);
436        for record in records {
437            bytes.extend_from_slice(&record.to_bytes());
438        }
439
440        let manifest_footer_offset = bytes.len() as u64;
441        let manifest_footer = build_manifest_footer(
442            &subkeys,
443            archive_uuid,
444            session_id,
445            volume_index,
446            options.stripe_width,
447            &index_root_extent,
448            index_root_plaintext.len(),
449        )?;
450        bytes.extend_from_slice(&manifest_footer);
451
452        let bytes_written = bytes.len() as u64;
453        let trailer = build_volume_trailer(
454            &subkeys,
455            archive_uuid,
456            session_id,
457            volume_index,
458            records.len() as u64,
459            bytes_written,
460            manifest_footer_offset,
461            options.closed_at_ns,
462        );
463        bytes.extend_from_slice(&trailer);
464
465        if volume_index == 0 {
466            volume_zero_manifest = manifest_footer;
467        }
468        volumes.push(bytes);
469    }
470
471    let bootstrap_sidecar = build_bootstrap_sidecar(
472        &subkeys,
473        archive_uuid,
474        session_id,
475        &volume_zero_manifest,
476        &index_root_extent.records,
477        dictionary_extent
478            .as_ref()
479            .map(|(object, _)| object.records.as_slice()),
480    )?;
481
482    let _ = index_shard_extent;
483
484    Ok(WrittenArchive {
485        bytes: volumes
486            .first()
487            .cloned()
488            .ok_or(FormatError::WriterInvariant("no volumes emitted"))?,
489        volumes,
490        bootstrap_sidecar,
491        archive_uuid,
492        session_id,
493    })
494}
495
496fn required_stripe_width_for_target(
497    archive: &WrittenArchive,
498    options: WriterOptions,
499    target_volume_size: u64,
500) -> Result<u32, FormatError> {
501    let max_volume_size = archive
502        .volumes
503        .iter()
504        .map(|volume| volume.len() as u64)
505        .max()
506        .unwrap_or(0);
507    if max_volume_size <= target_volume_size {
508        return Ok(options.stripe_width);
509    }
510
511    let first_volume = archive
512        .volumes
513        .first()
514        .ok_or(FormatError::WriterInvariant("no volumes emitted"))?;
515    let volume_header = VolumeHeader::parse(
516        first_volume
517            .get(..VOLUME_HEADER_LEN)
518            .ok_or(FormatError::WriterInvariant("truncated emitted volume"))?,
519    )?;
520    let fixed_volume_overhead = VOLUME_HEADER_LEN as u64
521        + volume_header.crypto_header_length as u64
522        + MANIFEST_FOOTER_LEN as u64
523        + VOLUME_TRAILER_LEN as u64;
524    if target_volume_size <= fixed_volume_overhead {
525        return Err(FormatError::WriterUnsupported(
526            "volume-size is too small for per-volume metadata",
527        ));
528    }
529
530    let block_record_len = options.block_size as u64 + BLOCK_RECORD_FRAMING_LEN as u64;
531    let records_per_volume = (target_volume_size - fixed_volume_overhead) / block_record_len;
532    if records_per_volume == 0 {
533        return Err(FormatError::WriterUnsupported(
534            "volume-size is too small for the configured block-size",
535        ));
536    }
537
538    let total_records = archive.volumes.iter().try_fold(0u64, |total, volume| {
539        let volume_len = volume.len() as u64;
540        if volume_len < fixed_volume_overhead {
541            return Err(FormatError::WriterInvariant("emitted volume too short"));
542        }
543        let record_bytes = volume_len - fixed_volume_overhead;
544        total
545            .checked_add(record_bytes / block_record_len)
546            .ok_or(FormatError::WriterUnsupported("volume count overflow"))
547    })?;
548    let required = ceil_div(total_records, records_per_volume)?
549        .max(options.volume_loss_tolerance as u64 + 1)
550        .max(1);
551    u32::try_from(required).map_err(|_| FormatError::WriterUnsupported("volume count"))
552}
553
554pub fn write_empty_archive(master_key: &MasterKey) -> Result<WrittenArchive, FormatError> {
555    write_archive(&[], master_key, WriterOptions::default())
556}
557
558fn plan_writer_options(mut options: WriterOptions) -> Result<WriterOptions, FormatError> {
559    if options.block_size < MIN_BLOCK_SIZE || options.block_size % 2 != 0 {
560        return Err(FormatError::WriterUnsupported(
561            "M6 writer requires an even block size of at least 4096",
562        ));
563    }
564    if options.stripe_width == 0 {
565        return Err(FormatError::WriterUnsupported(
566            "stripe_width must be non-zero",
567        ));
568    }
569    if options.volume_loss_tolerance as u32 >= options.stripe_width {
570        return Err(FormatError::WriterUnsupported(
571            "volume_loss_tolerance must be less than stripe_width",
572        ));
573    }
574    if options.stripe_width == 1 && options.volume_loss_tolerance != 0 {
575        return Err(FormatError::WriterUnsupported(
576            "single-volume archives cannot tolerate volume loss",
577        ));
578    }
579    if matches!(options.target_volume_size, Some(0)) {
580        return Err(FormatError::WriterUnsupported(
581            "target_volume_size must be non-zero",
582        ));
583    }
584    if options.bit_rot_buffer_pct > 100 {
585        return Err(FormatError::WriterUnsupported(
586            "bit_rot_buffer_pct must be at most 100",
587        ));
588    }
589    if options.chunk_size == 0 || options.chunk_size > options.envelope_target_size {
590        return Err(FormatError::WriterUnsupported(
591            "chunk_size must be non-zero and no larger than envelope_target_size",
592        ));
593    }
594    if options.fec_data_shards == 0
595        || options.index_fec_data_shards == 0
596        || options.index_root_fec_data_shards == 0
597    {
598        return Err(FormatError::WriterUnsupported(
599            "FEC data shard class maxima must be non-zero",
600        ));
601    }
602    options.index_root_fec_data_shards = options
603        .index_root_fec_data_shards
604        .max(MIN_INDEX_ROOT_FEC_DATA_SHARDS);
605    options.fec_parity_shards = options.fec_parity_shards.max(compute_parity_u16(
606        options.fec_data_shards as u64,
607        options,
608        "fec_parity_shards",
609    )?);
610    options.index_fec_parity_shards = options.index_fec_parity_shards.max(compute_parity_u16(
611        options.index_fec_data_shards as u64,
612        options,
613        "index_fec_parity_shards",
614    )?);
615    options.index_root_fec_parity_shards =
616        options.index_root_fec_parity_shards.max(compute_parity_u16(
617            options.index_root_fec_data_shards as u64,
618            options,
619            "index_root_fec_parity_shards",
620        )?);
621    Ok(options)
622}
623
624fn validate_m6_file_scope(file_count: usize) -> Result<(), FormatError> {
625    if file_count > DIRECTORY_HINT_REQUIRED_FILE_COUNT {
626        return Err(FormatError::WriterUnsupported(
627            "M6 writer does not emit required directory hint shards",
628        ));
629    }
630    if file_count > DEFAULT_FILES_PER_INDEX_SHARD {
631        return Err(FormatError::WriterUnsupported(
632            "M6 writer supports only one default-sized IndexShard",
633        ));
634    }
635    Ok(())
636}
637
638fn build_crypto_header(
639    options: WriterOptions,
640    has_dictionary: bool,
641    subkeys: &Subkeys,
642    archive_uuid: &[u8; 16],
643    session_id: &[u8; 16],
644    kdf_params: &KdfParams,
645) -> Result<Vec<u8>, FormatError> {
646    let kdf_payload = serialize_kdf_params(kdf_params)?;
647    let length = CRYPTO_HEADER_FIXED_LEN
648        .checked_add(kdf_payload.len())
649        .and_then(|value| value.checked_add(CRYPTO_EXTENSION_HEADER_LEN))
650        .and_then(|value| value.checked_add(CRYPTO_HEADER_HMAC_LEN))
651        .ok_or(FormatError::WriterUnsupported(
652            "CryptoHeader length overflow",
653        ))?;
654    let kdf_algo = match kdf_params {
655        KdfParams::Raw => KdfAlgo::Raw,
656        KdfParams::Argon2id { .. } => KdfAlgo::Argon2id,
657    };
658    let fixed = CryptoHeaderFixed {
659        length: length as u32,
660        compression_algo: CompressionAlgo::ZstdFramed,
661        aead_algo: options.aead_algo,
662        fec_algo: FecAlgo::ReedSolomonGF16,
663        kdf_algo,
664        chunk_size: options.chunk_size,
665        envelope_target_size: options.envelope_target_size,
666        block_size: options.block_size,
667        fec_data_shards: options.fec_data_shards,
668        fec_parity_shards: options.fec_parity_shards,
669        index_fec_data_shards: options.index_fec_data_shards,
670        index_fec_parity_shards: options.index_fec_parity_shards,
671        index_root_fec_data_shards: options.index_root_fec_data_shards,
672        index_root_fec_parity_shards: options.index_root_fec_parity_shards,
673        stripe_width: options.stripe_width,
674        volume_loss_tolerance: options.volume_loss_tolerance,
675        bit_rot_buffer_pct: options.bit_rot_buffer_pct,
676        has_dictionary: if has_dictionary { 1 } else { 0 },
677        max_path_length: options.max_path_length,
678        expected_volume_size: options.target_volume_size.unwrap_or(0),
679    };
680
681    let mut bytes = fixed.to_bytes().to_vec();
682    bytes.extend_from_slice(&kdf_payload);
683    bytes.extend_from_slice(&0u16.to_le_bytes());
684    bytes.extend_from_slice(&0u32.to_le_bytes());
685    let hmac = compute_hmac(
686        HmacDomain::CryptoHeader,
687        &subkeys.mac_key,
688        archive_uuid,
689        session_id,
690        &bytes,
691    );
692    bytes.extend_from_slice(&hmac);
693    Ok(bytes)
694}
695
696fn serialize_kdf_params(params: &KdfParams) -> Result<Vec<u8>, FormatError> {
697    let mut bytes = Vec::new();
698    match params {
699        KdfParams::Raw => {
700            bytes.extend_from_slice(&(KdfAlgo::Raw as u16).to_le_bytes());
701        }
702        KdfParams::Argon2id {
703            t_cost,
704            m_cost_kib,
705            parallelism,
706            salt,
707        } => {
708            if *t_cost == 0 {
709                return Err(FormatError::InvalidKdfParams("t_cost must be non-zero"));
710            }
711            if *parallelism == 0 {
712                return Err(FormatError::InvalidKdfParams(
713                    "parallelism must be non-zero",
714                ));
715            }
716            let min_memory = parallelism
717                .checked_mul(8)
718                .ok_or(FormatError::InvalidKdfParams(
719                    "m_cost_kib requirement overflow",
720                ))?;
721            if *m_cost_kib < min_memory {
722                return Err(FormatError::InvalidKdfParams(
723                    "m_cost_kib must be at least 8 * parallelism",
724                ));
725            }
726            if !(8..=64).contains(&salt.len()) {
727                return Err(FormatError::InvalidKdfParams(
728                    "argon2id salt length must be 8..64",
729                ));
730            }
731            let salt_len = u16::try_from(salt.len())
732                .map_err(|_| FormatError::InvalidKdfParams("argon2id salt too long"))?;
733            bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
734            bytes.extend_from_slice(&t_cost.to_le_bytes());
735            bytes.extend_from_slice(&m_cost_kib.to_le_bytes());
736            bytes.extend_from_slice(&parallelism.to_le_bytes());
737            bytes.extend_from_slice(&salt_len.to_le_bytes());
738            bytes.extend_from_slice(salt);
739        }
740    }
741    Ok(bytes)
742}
743
744fn build_tar_stream(
745    files: &[RegularFile<'_>],
746    max_path_length: u32,
747) -> Result<(Vec<u8>, Vec<TarMember>), FormatError> {
748    let mut stream = Vec::new();
749    let mut members = Vec::with_capacity(files.len());
750    for file in files {
751        let path = normalize_lookup_file_path(file.path, max_path_length)?;
752        let start = stream.len() as u64;
753        let member_group =
754            build_regular_file_member_group(&path, file.contents, file.mode, file.mtime)?;
755        stream.extend_from_slice(&member_group);
756        members.push(TarMember {
757            path,
758            tar_member_group_start: start,
759            tar_member_group_size: member_group.len() as u64,
760            file_data_size: file.contents.len() as u64,
761        });
762    }
763    Ok((stream, members))
764}
765
766fn build_payload_envelope(
767    tar_stream: &[u8],
768    members: &[TarMember],
769    options: WriterOptions,
770    dictionary: Option<&[u8]>,
771) -> Result<(Vec<u8>, Vec<PayloadFrame>), FormatError> {
772    let mut plaintext = Vec::new();
773    let mut frames = Vec::with_capacity(members.len());
774    for (index, member) in members.iter().enumerate() {
775        let start = member.tar_member_group_start as usize;
776        let end = checked_usize_add(start, member.tar_member_group_size as usize, "tar member")?;
777        let member_bytes = tar_stream
778            .get(start..end)
779            .ok_or(FormatError::WriterInvariant(
780                "tar member range is out of bounds",
781            ))?;
782        let frame = if let Some(dictionary) = dictionary {
783            compress_zstd_frame_with_dictionary(member_bytes, options.zstd_level, dictionary)?
784        } else {
785            compress_zstd_frame(member_bytes, options.zstd_level)?
786        };
787        let offset = u32_len(plaintext.len(), "FrameEntry.offset_in_envelope")?;
788        plaintext.extend_from_slice(&frame);
789        frames.push(PayloadFrame {
790            frame_index: index as u64,
791            offset_in_envelope: offset,
792            compressed_size: u32_len(frame.len(), "FrameEntry.compressed_size")?,
793            decompressed_size: u32_len(member_bytes.len(), "FrameEntry.decompressed_size")?,
794            tar_stream_offset: member.tar_member_group_start,
795        });
796    }
797    if plaintext.len() > options.envelope_target_size as usize {
798        return Err(FormatError::WriterUnsupported(
799            "M6 writer supports one small payload envelope",
800        ));
801    }
802    Ok((plaintext, frames))
803}
804
805fn build_single_index_shard(
806    members: &[TarMember],
807    frames: &[PayloadFrame],
808    payload: &EncryptedObject,
809    options: WriterOptions,
810) -> Result<Vec<u8>, FormatError> {
811    let mut file_rows = members
812        .iter()
813        .map(|member| {
814            let path_hash = hash_prefix(&member.path);
815            Ok((path_hash, member.path.clone(), member.clone()))
816        })
817        .collect::<Result<Vec<_>, FormatError>>()?;
818    file_rows.sort_by(|left, right| {
819        (left.0, left.1.as_slice(), left.2.tar_member_group_start).cmp(&(
820            right.0,
821            right.1.as_slice(),
822            right.2.tar_member_group_start,
823        ))
824    });
825
826    let mut string_pool = Vec::new();
827    let mut file_entries = Vec::with_capacity(file_rows.len());
828    for (path_hash, path, member) in file_rows {
829        let path_offset = u32_len(string_pool.len(), "FileEntry.path_offset")?;
830        string_pool.extend_from_slice(&path);
831        file_entries.push(FileEntry {
832            path_hash,
833            path_offset,
834            path_length: u32_len(path.len(), "FileEntry.path_length")?,
835            first_frame_index: member_frame_index(&member, frames)?,
836            frame_count: 1,
837            offset_in_first_frame_plaintext: 0,
838            tar_member_group_size: member.tar_member_group_size,
839            file_data_size: member.file_data_size,
840            flags: 0,
841        });
842    }
843
844    let frame_entries = frames
845        .iter()
846        .map(|frame| FrameEntry {
847            frame_index: frame.frame_index,
848            envelope_index: 0,
849            offset_in_envelope: frame.offset_in_envelope,
850            compressed_size: frame.compressed_size,
851            decompressed_size: frame.decompressed_size,
852            flags: 0x0000_0003,
853            tar_stream_offset: frame.tar_stream_offset,
854        })
855        .collect::<Vec<_>>();
856    let envelope_entries = vec![EnvelopeEntry {
857        envelope_index: 0,
858        first_block_index: payload.first_block_index,
859        data_block_count: payload.data_block_count,
860        parity_block_count: payload.parity_block_count,
861        encrypted_size: payload.encrypted_size,
862        plaintext_size: frames
863            .last()
864            .map(|frame| frame.offset_in_envelope + frame.compressed_size)
865            .unwrap_or(0),
866        first_frame_index: 0,
867        frame_count: u32_len(frames.len(), "EnvelopeEntry.frame_count")?,
868    }];
869
870    serialize_index_shard(
871        0,
872        &file_entries,
873        &frame_entries,
874        &envelope_entries,
875        &string_pool,
876        options,
877    )
878}
879
880fn serialize_index_shard(
881    shard_index: u64,
882    files: &[FileEntry],
883    frames: &[FrameEntry],
884    envelopes: &[EnvelopeEntry],
885    string_pool: &[u8],
886    _options: WriterOptions,
887) -> Result<Vec<u8>, FormatError> {
888    let mut cursor = INDEX_SHARD_HEADER_LEN;
889    let file_table_offset = table_offset(files.len(), cursor)?;
890    cursor = checked_usize_add(cursor, files.len() * FILE_ENTRY_LEN, "IndexShard")?;
891    let frame_table_offset = table_offset(frames.len(), cursor)?;
892    cursor = checked_usize_add(cursor, frames.len() * FRAME_ENTRY_LEN, "IndexShard")?;
893    let envelope_table_offset = table_offset(envelopes.len(), cursor)?;
894    cursor = checked_usize_add(cursor, envelopes.len() * ENVELOPE_ENTRY_LEN, "IndexShard")?;
895    let string_pool_offset = table_offset(string_pool.len(), cursor)?;
896
897    let header = IndexShardHeader {
898        version: 1,
899        shard_index,
900        file_count: u32_len(files.len(), "IndexShard.file_count")?,
901        frame_count: u32_len(frames.len(), "IndexShard.frame_count")?,
902        envelope_count: u32_len(envelopes.len(), "IndexShard.envelope_count")?,
903        file_table_offset,
904        frame_table_offset,
905        envelope_table_offset,
906        string_pool_offset,
907        string_pool_size: u32_len(string_pool.len(), "IndexShard.string_pool_size")?,
908    };
909
910    let mut bytes = Vec::with_capacity(cursor + string_pool.len());
911    bytes.extend_from_slice(&header.to_bytes());
912    for entry in files {
913        bytes.extend_from_slice(&entry.to_bytes());
914    }
915    for entry in frames {
916        bytes.extend_from_slice(&entry.to_bytes());
917    }
918    for entry in envelopes {
919        bytes.extend_from_slice(&entry.to_bytes());
920    }
921    bytes.extend_from_slice(string_pool);
922    Ok(bytes)
923}
924
925fn build_index_root_plaintext(
926    shard_entries: &[ShardEntry],
927    frame_count: u64,
928    envelope_count: u64,
929    file_count: u64,
930    payload_block_count: u64,
931    tar_total_size: u64,
932    content_sha256: [u8; 32],
933    dictionary_extent: Option<(&EncryptedObject, u32)>,
934) -> Vec<u8> {
935    let mut header = IndexRootHeader::empty();
936    header.frame_count = frame_count;
937    header.envelope_count = envelope_count;
938    header.file_count = file_count;
939    header.payload_block_count = payload_block_count;
940    header.tar_total_size = tar_total_size;
941    header.content_sha256 = content_sha256;
942    if let Some((dictionary, decompressed_size)) = dictionary_extent {
943        header.dictionary_first_block = dictionary.first_block_index;
944        header.dictionary_data_block_count = dictionary.data_block_count;
945        header.dictionary_parity_block_count = dictionary.parity_block_count;
946        header.dictionary_encrypted_size = dictionary.encrypted_size;
947        header.dictionary_decompressed_size = decompressed_size;
948    }
949    let root = IndexRoot {
950        header,
951        shards: shard_entries.to_vec(),
952        directory_hint_shards: Vec::new(),
953    };
954    root.to_bytes()
955}
956
957fn encrypt_object(
958    payload: &[u8],
959    key: &[u8; 32],
960    nonce_seed: &[u8; 32],
961    domain: &[u8],
962    counter: u64,
963    data_kind: BlockKind,
964    parity_kind: BlockKind,
965    data_shard_max: u16,
966    class_parity_shard_max: u16,
967    next_block_index: &mut u64,
968    options: WriterOptions,
969    archive_uuid: &[u8; 16],
970    session_id: &[u8; 16],
971) -> Result<EncryptedObject, FormatError> {
972    let block_size = options.block_size as usize;
973    let padded = suffix_pad_for_aead(payload, options.aead_algo.tag_len(), block_size)?;
974    let nonce = derive_nonce(
975        nonce_seed,
976        domain,
977        archive_uuid,
978        session_id,
979        counter,
980        options.aead_algo.nonce_len(),
981    )?;
982    let aad = build_aad(domain, archive_uuid, session_id, counter)?;
983    let encrypted = aead_encrypt(options.aead_algo, key, &nonce, &aad, &padded)?;
984    if encrypted.len() % block_size != 0 {
985        return Err(FormatError::WriterInvariant(
986            "encrypted object is not block aligned",
987        ));
988    }
989    let encrypted_size = u32_len(encrypted.len(), "encrypted_size")?;
990    let data_shards = encrypted
991        .chunks(block_size)
992        .map(|chunk| chunk.to_vec())
993        .collect::<Vec<_>>();
994    let data_block_count = u32_len(data_shards.len(), "data_block_count")?;
995    if data_block_count == 0 {
996        return Err(FormatError::WriterInvariant(
997            "encrypted object has no data blocks",
998        ));
999    }
1000    if data_block_count > data_shard_max as u32 {
1001        return Err(FormatError::WriterUnsupported(
1002            "encrypted object exceeds its data shard class maximum",
1003        ));
1004    }
1005    let required_parity = compute_object_parity(
1006        data_block_count as u64,
1007        options,
1008        class_parity_shard_max as u32,
1009    )?;
1010    if required_parity > class_parity_shard_max as u32 {
1011        return Err(FormatError::WriterUnsupported(
1012            "encrypted object exceeds its parity shard class maximum",
1013        ));
1014    }
1015    validate_object_shard_total(data_block_count, required_parity)?;
1016    let parity_count = required_parity as u16;
1017    let parity_shards = if parity_count == 0 {
1018        Vec::new()
1019    } else {
1020        encode_parity_gf16(&data_shards, parity_count as usize)?
1021    };
1022
1023    let first_block_index = *next_block_index;
1024    let mut records = Vec::with_capacity(data_shards.len() + parity_shards.len());
1025    for (index, payload) in data_shards.into_iter().enumerate() {
1026        records.push(BlockRecord {
1027            block_index: checked_u64_add(first_block_index, index as u64, "BlockRecord")?,
1028            kind: data_kind,
1029            flags: if index + 1 == data_block_count as usize {
1030                0x01
1031            } else {
1032                0
1033            },
1034            payload,
1035            record_crc32c: 0,
1036        });
1037    }
1038    let parity_first_block = checked_u64_add(first_block_index, data_block_count as u64, "FEC")?;
1039    for (index, payload) in parity_shards.into_iter().enumerate() {
1040        records.push(BlockRecord {
1041            block_index: checked_u64_add(parity_first_block, index as u64, "BlockRecord")?,
1042            kind: parity_kind,
1043            flags: 0,
1044            payload,
1045            record_crc32c: 0,
1046        });
1047    }
1048
1049    *next_block_index = checked_u64_add(
1050        first_block_index,
1051        data_block_count as u64 + parity_count as u64,
1052        "next_block_index",
1053    )?;
1054
1055    Ok(EncryptedObject {
1056        first_block_index,
1057        data_block_count,
1058        parity_block_count: parity_count as u32,
1059        encrypted_size,
1060        records,
1061    })
1062}
1063
1064fn build_manifest_footer(
1065    subkeys: &Subkeys,
1066    archive_uuid: [u8; 16],
1067    session_id: [u8; 16],
1068    volume_index: u32,
1069    total_volumes: u32,
1070    index_root_extent: &EncryptedObject,
1071    index_root_decompressed_size: usize,
1072) -> Result<[u8; MANIFEST_FOOTER_LEN], FormatError> {
1073    let mut footer = ManifestFooter {
1074        archive_uuid,
1075        session_id,
1076        volume_index,
1077        is_authoritative: 1,
1078        total_volumes,
1079        index_root_first_block: index_root_extent.first_block_index,
1080        index_root_data_block_count: index_root_extent.data_block_count,
1081        index_root_parity_block_count: index_root_extent.parity_block_count,
1082        index_root_encrypted_size: index_root_extent.encrypted_size,
1083        index_root_decompressed_size: u32_len(index_root_decompressed_size, "IndexRoot")?,
1084        manifest_hmac: [0u8; 32],
1085    };
1086    let mut bytes = footer.to_bytes();
1087    footer.manifest_hmac = compute_hmac(
1088        HmacDomain::ManifestFooter,
1089        &subkeys.mac_key,
1090        &archive_uuid,
1091        &session_id,
1092        &bytes[..104],
1093    );
1094    bytes = footer.to_bytes();
1095    Ok(bytes)
1096}
1097
1098fn build_volume_trailer(
1099    subkeys: &Subkeys,
1100    archive_uuid: [u8; 16],
1101    session_id: [u8; 16],
1102    volume_index: u32,
1103    block_count: u64,
1104    bytes_written: u64,
1105    manifest_footer_offset: u64,
1106    closed_at_ns: i64,
1107) -> [u8; VOLUME_TRAILER_LEN] {
1108    let mut trailer = VolumeTrailer {
1109        archive_uuid,
1110        session_id,
1111        volume_index,
1112        block_count,
1113        bytes_written,
1114        manifest_footer_offset,
1115        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
1116        closed_at_ns,
1117        trailer_hmac: [0u8; 32],
1118    };
1119    let mut bytes = trailer.to_bytes();
1120    trailer.trailer_hmac = compute_hmac(
1121        HmacDomain::VolumeTrailer,
1122        &subkeys.mac_key,
1123        &archive_uuid,
1124        &session_id,
1125        &bytes[..96],
1126    );
1127    bytes = trailer.to_bytes();
1128    bytes
1129}
1130
1131fn compute_object_parity(
1132    data_block_count: u64,
1133    options: WriterOptions,
1134    class_parity_shard_max: u32,
1135) -> Result<u32, FormatError> {
1136    let computed = compute_parity(data_block_count, options)?;
1137    if computed > class_parity_shard_max {
1138        return Err(FormatError::WriterUnsupported(
1139            "encrypted object exceeds its parity shard class maximum",
1140        ));
1141    }
1142    Ok(computed)
1143}
1144
1145fn validate_object_shard_total(
1146    data_block_count: u32,
1147    parity_block_count: u32,
1148) -> Result<(), FormatError> {
1149    let total = checked_u64_add(
1150        data_block_count as u64,
1151        parity_block_count as u64,
1152        "encrypted object shard total overflow",
1153    )?;
1154    if total > MAX_REED_SOLOMON_GF16_SHARDS {
1155        return Err(FormatError::WriterUnsupported(
1156            "encrypted object exceeds ReedSolomonGF16 shard limit",
1157        ));
1158    }
1159    Ok(())
1160}
1161
1162fn compute_parity_u16(
1163    data_block_count: u64,
1164    options: WriterOptions,
1165    field: &'static str,
1166) -> Result<u16, FormatError> {
1167    let parity = compute_parity(data_block_count, options)?;
1168    u16::try_from(parity).map_err(|_| FormatError::WriterUnsupported(field))
1169}
1170
1171fn compute_parity(data_block_count: u64, options: WriterOptions) -> Result<u32, FormatError> {
1172    let min_parity = if options.volume_loss_tolerance > 0 || options.bit_rot_buffer_pct > 0 {
1173        1u64
1174    } else {
1175        0u64
1176    };
1177    let mut parity = 0u64;
1178    for _ in 0..100 {
1179        let total = data_block_count
1180            .checked_add(parity)
1181            .ok_or(FormatError::WriterUnsupported("parity total overflow"))?;
1182        let by_volume = checked_u64_mul(
1183            options.volume_loss_tolerance as u64,
1184            ceil_div(total, options.stripe_width as u64)?,
1185            "volume-loss parity overflow",
1186        )?;
1187        let by_bitrot = ceil_div(
1188            checked_u64_mul(
1189                total,
1190                options.bit_rot_buffer_pct as u64,
1191                "bit-rot parity overflow",
1192            )?,
1193            100,
1194        )?;
1195        let next = by_volume
1196            .checked_add(by_bitrot)
1197            .ok_or(FormatError::WriterUnsupported("parity overflow"))?
1198            .max(min_parity);
1199        if next == parity {
1200            return u32::try_from(next).map_err(|_| FormatError::WriterUnsupported("parity count"));
1201        }
1202        parity = next;
1203    }
1204    Err(FormatError::WriterUnsupported(
1205        "parity calculation did not converge",
1206    ))
1207}
1208
1209fn ceil_div(numerator: u64, denominator: u64) -> Result<u64, FormatError> {
1210    if denominator == 0 {
1211        return Err(FormatError::WriterUnsupported("division by zero"));
1212    }
1213    numerator
1214        .checked_add(denominator - 1)
1215        .ok_or(FormatError::WriterUnsupported("ceiling division overflow"))
1216        .map(|value| value / denominator)
1217}
1218
1219fn checked_u64_mul(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
1220    lhs.checked_mul(rhs)
1221        .ok_or(FormatError::WriterUnsupported(field))
1222}
1223
1224fn build_bootstrap_sidecar(
1225    subkeys: &Subkeys,
1226    archive_uuid: [u8; 16],
1227    session_id: [u8; 16],
1228    manifest_footer: &[u8; MANIFEST_FOOTER_LEN],
1229    index_root_records: &[BlockRecord],
1230    dictionary_records: Option<&[BlockRecord]>,
1231) -> Result<Vec<u8>, FormatError> {
1232    let index_records_len = index_root_records.iter().try_fold(0usize, |sum, record| {
1233        checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
1234    })?;
1235    let dictionary_records_len = dictionary_records
1236        .unwrap_or(&[])
1237        .iter()
1238        .try_fold(0usize, |sum, record| {
1239            checked_usize_add(sum, record.to_bytes().len(), "bootstrap sidecar")
1240        })?;
1241    let manifest_offset = BOOTSTRAP_SIDECAR_HEADER_LEN as u64;
1242    let index_root_offset = manifest_offset + MANIFEST_FOOTER_LEN as u64;
1243    let dictionary_offset = if dictionary_records.is_some() {
1244        index_root_offset + index_records_len as u64
1245    } else {
1246        0
1247    };
1248    let mut header = BootstrapSidecarHeader {
1249        archive_uuid,
1250        session_id,
1251        flags: if dictionary_records.is_some() {
1252            0x07
1253        } else {
1254            0x03
1255        },
1256        manifest_footer_offset: manifest_offset,
1257        manifest_footer_length: MANIFEST_FOOTER_LEN as u32,
1258        index_root_records_offset: index_root_offset,
1259        index_root_records_length: index_records_len as u64,
1260        dictionary_records_offset: dictionary_offset,
1261        dictionary_records_length: dictionary_records_len as u64,
1262        sidecar_hmac: [0u8; 32],
1263        header_crc32c: 0,
1264    };
1265    let mut header_bytes = header.to_bytes();
1266    header.sidecar_hmac = compute_hmac(
1267        HmacDomain::BootstrapSidecar,
1268        &subkeys.mac_key,
1269        &archive_uuid,
1270        &session_id,
1271        &header_bytes[..92],
1272    );
1273    header_bytes = header.to_bytes();
1274
1275    let mut sidecar = Vec::with_capacity(
1276        BOOTSTRAP_SIDECAR_HEADER_LEN
1277            + MANIFEST_FOOTER_LEN
1278            + index_records_len
1279            + dictionary_records_len,
1280    );
1281    sidecar.extend_from_slice(&header_bytes);
1282    sidecar.extend_from_slice(manifest_footer);
1283    for record in index_root_records {
1284        sidecar.extend_from_slice(&record.to_bytes());
1285    }
1286    if let Some(dictionary_records) = dictionary_records {
1287        for record in dictionary_records {
1288            sidecar.extend_from_slice(&record.to_bytes());
1289        }
1290    }
1291    Ok(sidecar)
1292}
1293
1294fn build_regular_file_member_group(
1295    path: &[u8],
1296    contents: &[u8],
1297    mode: u32,
1298    mtime: u64,
1299) -> Result<Vec<u8>, FormatError> {
1300    let mut out = Vec::new();
1301    let header_path = if path_requires_pax(path) {
1302        let pax_payload = build_pax_record("path", path)?;
1303        let pax_header = build_ustar_header(
1304            b"PaxHeaders/path",
1305            pax_payload.len() as u64,
1306            0o644,
1307            mtime,
1308            b'x',
1309        )?;
1310        out.extend_from_slice(&pax_header);
1311        out.extend_from_slice(&pax_payload);
1312        out.resize(out.len() + padding_to_512(pax_payload.len()), 0);
1313        pax_ustar_fallback_path(path)
1314    } else {
1315        path.to_vec()
1316    };
1317
1318    let header = build_ustar_header(&header_path, contents.len() as u64, mode, mtime, b'0')?;
1319    out.extend_from_slice(&header);
1320    out.extend_from_slice(contents);
1321    out.resize(out.len() + padding_to_512(contents.len()), 0);
1322    Ok(out)
1323}
1324
1325fn path_requires_pax(path: &[u8]) -> bool {
1326    path.len() > 100 || !path.is_ascii()
1327}
1328
1329fn pax_ustar_fallback_path(path: &[u8]) -> Vec<u8> {
1330    path.rsplit(|byte| *byte == b'/')
1331        .next()
1332        .filter(|component| !component.is_empty() && component.len() <= 100 && component.is_ascii())
1333        .map(|component| component.to_vec())
1334        .unwrap_or_else(|| b"pax-file".to_vec())
1335}
1336
1337fn build_pax_record(key: &str, value: &[u8]) -> Result<Vec<u8>, FormatError> {
1338    let body_len = checked_usize_add(key.len(), 1, "PAX record")?;
1339    let body_len = checked_usize_add(body_len, value.len(), "PAX record")?;
1340    let body_len = checked_usize_add(body_len, 1, "PAX record")?;
1341    let mut digits = 1usize;
1342    loop {
1343        let len = checked_usize_add(digits, 1, "PAX record")?;
1344        let len = checked_usize_add(len, body_len, "PAX record")?;
1345        let next_digits = len.to_string().len();
1346        if next_digits == digits {
1347            let mut out = Vec::with_capacity(len);
1348            out.extend_from_slice(len.to_string().as_bytes());
1349            out.push(b' ');
1350            out.extend_from_slice(key.as_bytes());
1351            out.push(b'=');
1352            out.extend_from_slice(value);
1353            out.push(b'\n');
1354            return Ok(out);
1355        }
1356        digits = next_digits;
1357    }
1358}
1359
1360fn build_ustar_header(
1361    path: &[u8],
1362    size: u64,
1363    mode: u32,
1364    mtime: u64,
1365    typeflag: u8,
1366) -> Result<[u8; TAR_BLOCK_LEN], FormatError> {
1367    if path.len() > 100 {
1368        return Err(FormatError::WriterUnsupported(
1369            "ustar path exceeds name field",
1370        ));
1371    }
1372    let mut header = [0u8; TAR_BLOCK_LEN];
1373    header[0..path.len()].copy_from_slice(path);
1374    write_tar_octal(&mut header[100..108], mode as u64)?;
1375    write_tar_octal(&mut header[108..116], 0)?;
1376    write_tar_octal(&mut header[116..124], 0)?;
1377    write_tar_octal(&mut header[124..136], size)?;
1378    write_tar_octal(&mut header[136..148], mtime)?;
1379    header[148..156].fill(b' ');
1380    header[156] = typeflag;
1381    header[257..263].copy_from_slice(b"ustar\0");
1382    header[263..265].copy_from_slice(b"00");
1383    let checksum = header.iter().map(|byte| *byte as u32).sum::<u32>() as u64;
1384    write_tar_checksum(&mut header[148..156], checksum)?;
1385    Ok(header)
1386}
1387
1388fn write_tar_octal(field: &mut [u8], value: u64) -> Result<(), FormatError> {
1389    let digits = format!("{value:o}");
1390    if digits.len() + 1 > field.len() {
1391        return Err(FormatError::WriterUnsupported("tar octal field overflow"));
1392    }
1393    field.fill(0);
1394    let padding = field.len() - 1 - digits.len();
1395    for byte in &mut field[..padding] {
1396        *byte = b'0';
1397    }
1398    field[padding..padding + digits.len()].copy_from_slice(digits.as_bytes());
1399    Ok(())
1400}
1401
1402fn write_tar_checksum(field: &mut [u8], value: u64) -> Result<(), FormatError> {
1403    let digits = format!("{value:06o}");
1404    if digits.len() != 6 {
1405        return Err(FormatError::WriterUnsupported(
1406            "tar checksum field overflow",
1407        ));
1408    }
1409    field[0..6].copy_from_slice(digits.as_bytes());
1410    field[6] = 0;
1411    field[7] = b' ';
1412    Ok(())
1413}
1414
1415fn member_frame_index(member: &TarMember, frames: &[PayloadFrame]) -> Result<u64, FormatError> {
1416    frames
1417        .iter()
1418        .find(|frame| frame.tar_stream_offset == member.tar_member_group_start)
1419        .map(|frame| frame.frame_index)
1420        .ok_or(FormatError::WriterInvariant("member frame is missing"))
1421}
1422
1423fn file_hash_bounds(members: &[TarMember]) -> Result<([u8; 8], [u8; 8]), FormatError> {
1424    let first = members
1425        .iter()
1426        .map(|member| hash_prefix(&member.path))
1427        .min()
1428        .ok_or(FormatError::WriterInvariant("missing first hash"))?;
1429    let last = members
1430        .iter()
1431        .map(|member| hash_prefix(&member.path))
1432        .max()
1433        .ok_or(FormatError::WriterInvariant("missing last hash"))?;
1434    Ok((first, last))
1435}
1436
1437fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
1438    let digest = Sha256::digest(bytes);
1439    let mut out = [0u8; 32];
1440    out.copy_from_slice(&digest);
1441    out
1442}
1443
1444fn padding_to_512(len: usize) -> usize {
1445    let remainder = len % TAR_BLOCK_LEN;
1446    if remainder == 0 {
1447        0
1448    } else {
1449        TAR_BLOCK_LEN - remainder
1450    }
1451}
1452
1453fn table_offset(len: usize, cursor: usize) -> Result<u32, FormatError> {
1454    if len == 0 {
1455        Ok(0)
1456    } else {
1457        u32_len(cursor, "table offset")
1458    }
1459}
1460
1461fn u32_len(value: usize, field: &'static str) -> Result<u32, FormatError> {
1462    u32::try_from(value).map_err(|_| FormatError::WriterUnsupported(field))
1463}
1464
1465fn checked_usize_add(lhs: usize, rhs: usize, field: &'static str) -> Result<usize, FormatError> {
1466    lhs.checked_add(rhs)
1467        .ok_or(FormatError::WriterUnsupported(field))
1468}
1469
1470fn checked_u64_add(lhs: u64, rhs: u64, field: &'static str) -> Result<u64, FormatError> {
1471    lhs.checked_add(rhs)
1472        .ok_or(FormatError::WriterUnsupported(field))
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477    use super::*;
1478    use crate::crypto::{verify_hmac, Subkeys};
1479    use crate::tar_model::parse_tar_member_group;
1480    use crate::wire::CryptoHeader;
1481
1482    #[test]
1483    fn writer_defaults_use_v36_sizing_and_parallel_mode() {
1484        let options = WriterOptions::default();
1485
1486        assert_eq!(options.chunk_size, 256 * 1024);
1487        assert_eq!(options.envelope_target_size, 1024 * 1024);
1488        assert_eq!(options.block_size, 64 * 1024);
1489        assert_eq!(options.stripe_width, 8);
1490        assert_eq!(options.volume_loss_tolerance, 1);
1491        assert_eq!(options.fec_data_shards, 224);
1492        assert_eq!(options.index_fec_data_shards, 16);
1493        assert_eq!(
1494            options.index_root_fec_data_shards,
1495            MIN_INDEX_ROOT_FEC_DATA_SHARDS
1496        );
1497        assert_eq!(options.bit_rot_buffer_pct, 5);
1498    }
1499
1500    #[test]
1501    fn m6_scope_rejects_archives_that_require_directory_hints() {
1502        assert_eq!(
1503            validate_m6_file_scope(DIRECTORY_HINT_REQUIRED_FILE_COUNT + 1).unwrap_err(),
1504            FormatError::WriterUnsupported(
1505                "M6 writer does not emit required directory hint shards"
1506            )
1507        );
1508    }
1509
1510    #[test]
1511    fn m6_scope_rejects_archives_that_need_multiple_default_sized_index_shards() {
1512        assert!(validate_m6_file_scope(DEFAULT_FILES_PER_INDEX_SHARD).is_ok());
1513        assert_eq!(
1514            validate_m6_file_scope(DEFAULT_FILES_PER_INDEX_SHARD + 1).unwrap_err(),
1515            FormatError::WriterUnsupported("M6 writer supports only one default-sized IndexShard")
1516        );
1517    }
1518
1519    #[test]
1520    fn regular_file_writer_uses_local_pax_path_for_long_and_non_ascii_paths() {
1521        let long_path = format!("dir/{}.txt", "a".repeat(120));
1522        let unicode_path = "unicode/e\u{301}.txt";
1523        let files = [
1524            RegularFile::new(&long_path, b"long path"),
1525            RegularFile::new(unicode_path, b"unicode path"),
1526        ];
1527
1528        let (tar_stream, members) = build_tar_stream(&files, 4096).unwrap();
1529
1530        for (member, expected_path, expected_data) in [
1531            (&members[0], long_path.as_bytes(), b"long path".as_slice()),
1532            (
1533                &members[1],
1534                "unicode/\u{e9}.txt".as_bytes(),
1535                b"unicode path".as_slice(),
1536            ),
1537        ] {
1538            let start = member.tar_member_group_start as usize;
1539            let end = start + member.tar_member_group_size as usize;
1540            let group = &tar_stream[start..end];
1541            assert_eq!(group[156], b'x');
1542            let parsed = parse_tar_member_group(group, 4096).unwrap();
1543            assert_eq!(parsed.path, expected_path);
1544            assert_eq!(parsed.data, expected_data);
1545        }
1546    }
1547
1548    #[test]
1549    fn writes_empty_archive_with_authentic_bootstrap_structures() {
1550        let master_key = MasterKey::from_raw_key(&[7u8; 32]).unwrap();
1551        let archive = write_empty_archive(&master_key).unwrap();
1552        let bytes = archive.bytes;
1553
1554        let volume_header = VolumeHeader::parse(&bytes[..VOLUME_HEADER_LEN]).unwrap();
1555        assert_eq!(volume_header.archive_uuid, archive.archive_uuid);
1556        assert_eq!(volume_header.session_id, archive.session_id);
1557
1558        let crypto_start = VOLUME_HEADER_LEN;
1559        let crypto_end = crypto_start + volume_header.crypto_header_length as usize;
1560        let crypto_header = CryptoHeader::parse(
1561            &bytes[crypto_start..crypto_end],
1562            volume_header.crypto_header_length,
1563        )
1564        .unwrap();
1565        let subkeys =
1566            Subkeys::derive(&master_key, &archive.archive_uuid, &archive.session_id).unwrap();
1567        verify_hmac(
1568            HmacDomain::CryptoHeader,
1569            &subkeys.mac_key,
1570            &archive.archive_uuid,
1571            &archive.session_id,
1572            crypto_header.hmac_covered_bytes,
1573            &crypto_header.header_hmac,
1574        )
1575        .unwrap();
1576
1577        let trailer_offset = bytes.len() - VOLUME_TRAILER_LEN;
1578        let trailer = VolumeTrailer::parse(&bytes[trailer_offset..]).unwrap();
1579        assert_eq!(trailer.bytes_written, trailer_offset as u64);
1580        verify_hmac(
1581            HmacDomain::VolumeTrailer,
1582            &subkeys.mac_key,
1583            &archive.archive_uuid,
1584            &archive.session_id,
1585            &bytes[trailer_offset..trailer_offset + 96],
1586            &trailer.trailer_hmac,
1587        )
1588        .unwrap();
1589
1590        let manifest_offset = trailer.manifest_footer_offset as usize;
1591        let manifest_end = manifest_offset + MANIFEST_FOOTER_LEN;
1592        let manifest = ManifestFooter::parse(&bytes[manifest_offset..manifest_end]).unwrap();
1593        assert_eq!(manifest.is_authoritative, 1);
1594        assert_eq!(manifest.total_volumes, DEFAULT_STRIPE_WIDTH);
1595        verify_hmac(
1596            HmacDomain::ManifestFooter,
1597            &subkeys.mac_key,
1598            &archive.archive_uuid,
1599            &archive.session_id,
1600            &bytes[manifest_offset..manifest_offset + 104],
1601            &manifest.manifest_hmac,
1602        )
1603        .unwrap();
1604    }
1605
1606    #[test]
1607    fn parity_auto_scaling_matches_v36_examples() {
1608        let options = WriterOptions {
1609            fec_data_shards: 224,
1610            stripe_width: 8,
1611            volume_loss_tolerance: 1,
1612            bit_rot_buffer_pct: 5,
1613            ..WriterOptions::default()
1614        };
1615
1616        assert_eq!(compute_parity(224, options).unwrap(), 48);
1617        assert_eq!(compute_parity(17, options).unwrap(), 5);
1618    }
1619
1620    #[test]
1621    fn zero_parity_is_allowed_when_no_recovery_margin_is_requested() {
1622        let planned = plan_writer_options(WriterOptions {
1623            bit_rot_buffer_pct: 0,
1624            stripe_width: 1,
1625            volume_loss_tolerance: 0,
1626            fec_parity_shards: 0,
1627            index_fec_parity_shards: 0,
1628            index_root_fec_parity_shards: 0,
1629            ..WriterOptions::default()
1630        })
1631        .unwrap();
1632
1633        assert_eq!(planned.fec_parity_shards, 0);
1634        assert_eq!(planned.index_fec_parity_shards, 0);
1635        assert_eq!(planned.index_root_fec_parity_shards, 0);
1636        assert_eq!(compute_parity(1, planned).unwrap(), 0);
1637    }
1638
1639    #[test]
1640    fn index_root_data_shard_maximum_obeys_v36_minimum() {
1641        let planned = plan_writer_options(WriterOptions {
1642            index_root_fec_data_shards: 1,
1643            ..WriterOptions::default()
1644        })
1645        .unwrap();
1646
1647        assert_eq!(
1648            planned.index_root_fec_data_shards,
1649            MIN_INDEX_ROOT_FEC_DATA_SHARDS
1650        );
1651    }
1652
1653    #[test]
1654    fn object_parity_uses_per_object_recurrence_even_with_larger_class_max() {
1655        let options = WriterOptions {
1656            bit_rot_buffer_pct: 0,
1657            stripe_width: 1,
1658            volume_loss_tolerance: 0,
1659            fec_parity_shards: 1,
1660            ..WriterOptions::default()
1661        };
1662
1663        assert_eq!(compute_object_parity(1, options, 1).unwrap(), 0);
1664    }
1665
1666    #[test]
1667    fn object_total_shards_obeys_reed_solomon_limit() {
1668        assert!(validate_object_shard_total(65_535, 0).is_ok());
1669        assert_eq!(
1670            validate_object_shard_total(65_535, 1).unwrap_err(),
1671            FormatError::WriterUnsupported("encrypted object exceeds ReedSolomonGF16 shard limit")
1672        );
1673    }
1674
1675    #[test]
1676    fn argon2id_kdf_serialization_rejects_memory_requirement_overflow() {
1677        assert_eq!(
1678            serialize_kdf_params(&KdfParams::Argon2id {
1679                t_cost: 1,
1680                m_cost_kib: u32::MAX,
1681                parallelism: u32::MAX,
1682                salt: b"12345678".to_vec(),
1683            })
1684            .unwrap_err(),
1685            FormatError::InvalidKdfParams("m_cost_kib requirement overflow")
1686        );
1687    }
1688}