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(¶llelism.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}