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