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