ferrocrypt 0.3.0

Recipient-oriented file and directory encryption: passphrase (Argon2id) and X25519 public-key recipients, XChaCha20-Poly1305 STREAM payloads, HKDF-SHA3-256 / HMAC-SHA3-256 key derivation and authentication.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
//! Resource caps for FCA archive encoding and extraction.
//!
//! `ArchiveLimits` bounds entry count, total plaintext bytes, path depth,
//! per-path UTF-8 byte length, serialized manifest byte length, and the
//! per-region, total, and per-value caps that bound FCA forward-compatibility
//! TLV regions. See `ferrocrypt-lib/FORMAT.md` §9.12.
//!
//! Both writer and reader apply each cap before the allocation, content copy,
//! or filesystem work that cap bounds — readers during header / manifest
//! parse, writers progressively during the metadata pass. The same struct is
//! reused on both sides so a writer cannot exceed the archive caps of a
//! reader using the same configuration.

use crate::CryptoError;
use crate::error::sanitize_for_display;

use super::reasons::{TOTAL_ENTRY_EXT_BYTES_OVERFLOW, TOTAL_FILE_BYTES_OVERFLOW};

/// Resource caps for FCA archive encoding and extraction.
///
/// The defaults suit ordinary files and directory trees. Raise a cap only
/// for input of a known origin that legitimately exceeds it, and pass the
/// same value to both sides when possible. Reader limits must admit the
/// archive's actual contents, regardless of the writer's configured ceilings.
///
/// Every cap has a published `*_DEFAULT` constant, so adjusting one cap
/// relative to the defaults does not mean copying a number out of
/// `FORMAT.md` §9.12. Only [`ArchiveLimits::max_path_bytes`] has a
/// structural ceiling — the on-disk `path_len` field is a `u16` — and its
/// builder method clamps there, so safe downstream code cannot construct a
/// cap the format cannot represent. The other caps are resource policy alone
/// and accept any value their type holds.
///
/// Archive caps are enforced at the earliest point their authenticated FCA
/// values are available. Encryption applies them during archive preflight,
/// before key derivation or output staging. Decryption must first authenticate
/// and begin decrypting the FCA payload; each cap then runs before the
/// allocation, content copy, or filesystem work that it bounds, and complete
/// manifest validation still precedes filesystem output.
///
/// The archive count, size, path, and extension-region caps surface as
/// dedicated `Archive*CapExceeded` errors. A TLV value above
/// `max_tlv_value_bytes` is instead a TLV grammar defect and surfaces as
/// `CryptoError::InvalidFormat(FormatDefect::MalformedTlv)`.
///
/// Pass a value to [`crate::Encryptor::archive_limits`],
/// [`crate::PassphraseDecryptor::archive_limits`], or
/// [`crate::PrivateKeyDecryptor::archive_limits`]. The struct is
/// `#[non_exhaustive]` so future releases can add further caps without a
/// breaking change.
///
/// This type remains `Copy`: its resource caps are numeric bounds, including
/// any caps added in future releases.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ArchiveLimits {
    /// Maximum number of manifest entries (regular files plus directories).
    pub(crate) max_entry_count: u32,
    /// Maximum cumulative declared logical bytes across regular-file
    /// entries. Directory entries do not contribute.
    pub(crate) max_total_plaintext_bytes: u64,
    /// Maximum path component count for any single archive entry.
    pub(crate) max_path_depth: u32,
    /// Maximum UTF-8 byte length of any single archive path. Public
    /// construction keeps this at or below
    /// [`ArchiveLimits::PATH_BYTES_STRUCTURAL_MAX`], because the on-disk
    /// `path_len` field is a `u16`.
    pub(crate) max_path_bytes: u32,
    /// Maximum byte length of the serialized manifest, including the
    /// per-entry TLV regions that live inside it.
    pub(crate) max_manifest_bytes: u32,
    /// Maximum byte length of the FCA archive-level TLV region
    /// (`archive_ext`). The FCA fixed header's `archive_ext_len` field
    /// is rejected before allocation if it exceeds this cap.
    pub(crate) max_archive_ext_bytes: u32,
    /// Maximum byte length of any single per-entry TLV region
    /// (`entry_ext`).
    pub(crate) max_entry_ext_bytes: u32,
    /// Maximum cumulative byte length of all per-entry TLV regions in
    /// one manifest. Bounds memory used by extension metadata across
    /// the archive even when individual entries fit under
    /// `max_entry_ext_bytes`.
    pub(crate) max_total_entry_ext_bytes: u64,
    /// Maximum byte length of any single TLV value inside an FCA
    /// archive- or entry-level TLV region. Defense-in-depth: the
    /// containing region cap will fire first today, but a future
    /// region with a larger cap still bounds individual values.
    pub(crate) max_tlv_value_bytes: u32,
}

impl ArchiveLimits {
    /// Structural maximum for the per-path byte cap (65,535 bytes): the
    /// on-disk `path_len` field is a `u16` (`FORMAT.md` §9.12).
    /// [`ArchiveLimits::max_path_bytes`] clamps at this value. It is the
    /// only cap the format itself bounds; the rest are resource policy.
    pub const PATH_BYTES_STRUCTURAL_MAX: u32 = u16::MAX as u32;

    /// Default manifest entry count (250,000 entries).
    pub const ENTRY_COUNT_DEFAULT: u32 = 250_000;
    /// Default cumulative logical regular-file byte count (64 GiB).
    pub const TOTAL_PLAINTEXT_BYTES_DEFAULT: u64 = 64 * 1024 * 1024 * 1024;
    /// Default per-path component count (64 components).
    pub const PATH_DEPTH_DEFAULT: u32 = 64;
    /// Default per-path UTF-8 byte length (4,096 bytes).
    pub const PATH_BYTES_DEFAULT: u32 = 4096;
    /// Default serialized manifest byte length (64 MiB).
    pub const MANIFEST_BYTES_DEFAULT: u32 = 64 * 1024 * 1024;
    /// Default archive-level TLV region byte length (64 KiB).
    pub const ARCHIVE_EXT_BYTES_DEFAULT: u32 = 64 * 1024;
    /// Default per-entry TLV region byte length (64 KiB).
    pub const ENTRY_EXT_BYTES_DEFAULT: u32 = 64 * 1024;
    /// Default cumulative per-entry TLV byte length (64 MiB).
    pub const TOTAL_ENTRY_EXT_BYTES_DEFAULT: u64 = 64 * 1024 * 1024;
    /// Default single-TLV value byte length (16 MiB).
    pub const TLV_VALUE_BYTES_DEFAULT: u32 = 16 * 1024 * 1024;

    /// Sets the maximum number of manifest entries. A source tree or a
    /// manifest above the cap rejects with
    /// [`CryptoError::ArchiveEntryCountCapExceeded`] before per-entry
    /// state is allocated.
    pub fn max_entry_count(mut self, value: u32) -> Self {
        self.max_entry_count = value;
        self
    }

    /// Sets the maximum cumulative logical byte count across regular-file
    /// entries. A larger total rejects with
    /// [`CryptoError::ArchiveTotalBytesCapExceeded`] before any file
    /// content is copied.
    pub fn max_total_plaintext_bytes(mut self, value: u64) -> Self {
        self.max_total_plaintext_bytes = value;
        self
    }

    /// Sets the maximum path component count for one archive entry. A
    /// deeper path rejects with
    /// [`CryptoError::ArchivePathDepthCapExceeded`] during source validation
    /// or manifest parsing, before extraction.
    pub fn max_path_depth(mut self, value: u32) -> Self {
        self.max_path_depth = value;
        self
    }

    /// Sets the maximum UTF-8 byte length of one archive path, clamped at
    /// [`Self::PATH_BYTES_STRUCTURAL_MAX`]. A longer path rejects with
    /// [`CryptoError::ArchivePathBytesCapExceeded`]. During decoding, the
    /// declared length is checked before allocating the path string.
    pub fn max_path_bytes(mut self, value: u32) -> Self {
        self.max_path_bytes = value.min(Self::PATH_BYTES_STRUCTURAL_MAX);
        self
    }

    /// Sets the maximum serialized manifest byte length, per-entry TLV
    /// regions included. A larger manifest rejects with
    /// [`CryptoError::ArchiveManifestLenCapExceeded`] before the manifest
    /// buffer is allocated.
    pub fn max_manifest_bytes(mut self, value: u32) -> Self {
        self.max_manifest_bytes = value;
        self
    }

    /// Sets the maximum archive-level TLV region byte length. A larger
    /// declared region rejects with
    /// [`CryptoError::ArchiveExtLenCapExceeded`] before the extension
    /// buffer is allocated.
    pub fn max_archive_ext_bytes(mut self, value: u32) -> Self {
        self.max_archive_ext_bytes = value;
        self
    }

    /// Sets the maximum byte length of one per-entry TLV region. A larger
    /// region rejects with
    /// [`CryptoError::ArchiveEntryExtLenCapExceeded`] before its bytes are
    /// sliced.
    pub fn max_entry_ext_bytes(mut self, value: u32) -> Self {
        self.max_entry_ext_bytes = value;
        self
    }

    /// Sets the maximum cumulative byte length of every per-entry TLV
    /// region in one manifest. A larger total rejects with
    /// [`CryptoError::ArchiveTotalEntryExtCapExceeded`] while the manifest
    /// is parsed.
    pub fn max_total_entry_ext_bytes(mut self, value: u64) -> Self {
        self.max_total_entry_ext_bytes = value;
        self
    }

    /// Sets the maximum byte length of one TLV value inside an FCA
    /// archive- or entry-level region. A region holding a longer value
    /// rejects with [`CryptoError::InvalidFormat`] carrying
    /// [`FormatDefect::MalformedTlv`](crate::FormatDefect::MalformedTlv)
    /// while it is validated, not with an `Archive*CapExceeded` error
    /// (`FORMAT.md` §9.12).
    pub fn max_tlv_value_bytes(mut self, value: u32) -> Self {
        self.max_tlv_value_bytes = value;
        self
    }
}

/// The default path-byte cap must itself be representable on the wire, or
/// every unconfigured operation would carry an impossible cap.
const _: () =
    assert!(ArchiveLimits::PATH_BYTES_DEFAULT <= ArchiveLimits::PATH_BYTES_STRUCTURAL_MAX);

impl Default for ArchiveLimits {
    fn default() -> Self {
        Self {
            max_entry_count: Self::ENTRY_COUNT_DEFAULT,
            max_total_plaintext_bytes: Self::TOTAL_PLAINTEXT_BYTES_DEFAULT,
            max_path_depth: Self::PATH_DEPTH_DEFAULT,
            max_path_bytes: Self::PATH_BYTES_DEFAULT,
            max_manifest_bytes: Self::MANIFEST_BYTES_DEFAULT,
            max_archive_ext_bytes: Self::ARCHIVE_EXT_BYTES_DEFAULT,
            max_entry_ext_bytes: Self::ENTRY_EXT_BYTES_DEFAULT,
            max_total_entry_ext_bytes: Self::TOTAL_ENTRY_EXT_BYTES_DEFAULT,
            max_tlv_value_bytes: Self::TLV_VALUE_BYTES_DEFAULT,
        }
    }
}

/// Per-entry resource-cap check for the encrypt-side preflight
/// (`archive::encode`). The decode side does not call this wrapper —
/// manifest parsing and tree validation run the same underlying
/// per-cap helpers directly — so each cap rule still lives in exactly
/// one place. Caller has already incremented `entry_count` for the
/// current entry.
pub(crate) fn enforce_per_entry_caps(
    entry_count: u32,
    path_utf8: &str,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    enforce_entry_count_cap(entry_count, limits)?;
    enforce_path_depth_cap(path_utf8, limits)?;
    Ok(())
}

/// Single source of truth for `entry_count > max_entry_count`. Used by
/// the writer's metadata-pass per-entry check
/// ([`enforce_per_entry_caps`]), the reader's [`parse_fca_header`](crate::archive::format::parse_fca_header)
/// header-field check, and the post-parse manifest-tree validator.
pub(crate) fn enforce_entry_count_cap(
    entry_count: u32,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    if entry_count > limits.max_entry_count {
        return Err(entry_count_cap_error(entry_count, limits.max_entry_count));
    }
    Ok(())
}

/// Counts the `/`-separated components of an FCA path. `validate_fca_path`
/// (in `archive::path`) guarantees single `/` separators with no leading
/// or trailing slash, so this count matches the `max_path_depth` cap
/// exactly. The single source of truth for the split, shared by
/// [`enforce_path_depth_cap`] here and `archive::path::canonical_path_order`.
pub(super) fn component_count(path: &str) -> usize {
    path.split('/').count()
}

/// Single source of truth for path-depth cap enforcement. Computes the
/// depth from the FCA UTF-8 path (count of `/`-separated components)
/// so callers don't reimplement the split. Used by [`validate_fca_path`](crate::archive::path::validate_fca_path)
/// (path grammar) and [`enforce_per_entry_caps`] (writer metadata pass).
pub(crate) fn enforce_path_depth_cap(
    path_utf8: &str,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    let depth = u32::try_from(component_count(path_utf8)).unwrap_or(u32::MAX);
    if depth > limits.max_path_depth {
        return Err(path_depth_cap_error(
            depth,
            limits.max_path_depth,
            path_utf8,
        ));
    }
    Ok(())
}

/// Single source of truth for the per-entry path-byte cap. Used by:
/// the writer's [`crate::archive::format::checked_manifest_len`] (with
/// the entry path), the reader's
/// [`crate::archive::format::parse_manifest_bytes`] pre-allocation
/// guard (without a path yet), and [`validate_fca_path`](crate::archive::path::validate_fca_path) (after the
/// path string has been resolved).
pub(crate) fn enforce_path_bytes_cap(
    path_len: u32,
    path: Option<&str>,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    if path_len > limits.max_path_bytes {
        return Err(path_bytes_cap_error(path_len, limits.max_path_bytes, path));
    }
    Ok(())
}

/// Single source of truth for `manifest_len > max_manifest_bytes`. Used
/// by the reader's [`parse_fca_header`](crate::archive::format::parse_fca_header) (header field check) and the
/// writer's [`crate::archive::format::checked_manifest_len`] running
/// total. Takes `u64` so a `u32` wire field and a `usize` running total
/// both fit.
pub(crate) fn enforce_manifest_len_cap(
    manifest_len: u64,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    if manifest_len > u64::from(limits.max_manifest_bytes) {
        return Err(manifest_len_cap_error(
            manifest_len,
            limits.max_manifest_bytes,
        ));
    }
    Ok(())
}

/// Single source of truth for `archive_ext_len > max_archive_ext_bytes`.
/// Used by [`parse_fca_header`](crate::archive::format::parse_fca_header) (only call site today; native writers emit
/// `archive_ext_len = 0` so the writer-side check is implicit, but a
/// future writer that emits a non-zero region would call this).
pub(crate) fn enforce_archive_ext_cap(
    archive_ext_len: u64,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    if archive_ext_len > u64::from(limits.max_archive_ext_bytes) {
        return Err(archive_ext_cap_error(
            archive_ext_len,
            limits.max_archive_ext_bytes,
        ));
    }
    Ok(())
}

/// One-shot total-bytes cap check (no running mutator). Used by the
/// reader's [`parse_fca_header`](crate::archive::format::parse_fca_header) (header field), reader's
/// [`crate::archive::format::parse_manifest_bytes`] post-sum
/// re-validation, and the manifest-tree validator. The writer's
/// metadata pass uses [`enforce_total_bytes_cap`] (running mutator)
/// instead.
pub(crate) fn enforce_total_plaintext_bytes_cap(
    total_file_bytes: u64,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    if total_file_bytes > limits.max_total_plaintext_bytes {
        return Err(total_bytes_cap_error(
            total_file_bytes,
            limits.max_total_plaintext_bytes,
        ));
    }
    Ok(())
}

/// Per-file-entry total-bytes accumulator for the encrypt-side
/// preflight (`archive::encode`). The decode side keeps its own checked
/// running sum and applies the same `enforce_total_plaintext_bytes_cap`
/// rule during manifest tree validation. Updates `total_bytes` in place
/// using checked arithmetic so an overflow is rejected even when
/// callers raise `max_total_plaintext_bytes` to `u64::MAX`.
pub(crate) fn enforce_total_bytes_cap(
    entry_size: u64,
    total_bytes: &mut u64,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    let next = total_bytes
        .checked_add(entry_size)
        .ok_or(CryptoError::MalformedArchive {
            reason: TOTAL_FILE_BYTES_OVERFLOW,
        })?;
    if next > limits.max_total_plaintext_bytes {
        return Err(total_bytes_cap_error(
            next,
            limits.max_total_plaintext_bytes,
        ));
    }
    *total_bytes = next;
    Ok(())
}

/// Per-entry `entry_ext` cap check, shared by writer-side
/// `checked_manifest_len` (which knows the entry path) and reader-side
/// `parse_manifest_bytes` (which has only parsed the length so far).
/// Single source of truth for the `entry_ext_len > max_entry_ext_bytes`
/// rejection so a future cap rename only touches one place.
pub(crate) fn enforce_entry_ext_cap(
    entry_ext_len: u64,
    path: Option<&str>,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    if entry_ext_len > u64::from(limits.max_entry_ext_bytes) {
        return Err(entry_ext_cap_error(
            entry_ext_len,
            limits.max_entry_ext_bytes,
            path,
        ));
    }
    Ok(())
}

/// Per-entry TLV-region total cap, shared by writer-side
/// `checked_manifest_len` and reader-side `parse_manifest_bytes`.
/// Mirrors [`enforce_total_bytes_cap`]: checked-add + cap, with the
/// same `&mut u64` running-total convention so overflow is rejected
/// even at `max_total_entry_ext_bytes = u64::MAX`.
pub(crate) fn enforce_total_entry_ext_cap(
    entry_ext_len: u64,
    total: &mut u64,
    limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
    let next = total
        .checked_add(entry_ext_len)
        .ok_or(CryptoError::MalformedArchive {
            reason: TOTAL_ENTRY_EXT_BYTES_OVERFLOW,
        })?;
    if next > limits.max_total_entry_ext_bytes {
        return Err(total_entry_ext_cap_error(
            next,
            limits.max_total_entry_ext_bytes,
        ));
    }
    *total = next;
    Ok(())
}

pub(super) fn entry_count_cap_error(entry_count: u32, cap: u32) -> CryptoError {
    CryptoError::ArchiveEntryCountCapExceeded {
        entry_count,
        local_cap: cap,
    }
}

pub(super) fn total_bytes_cap_error(total: u64, cap: u64) -> CryptoError {
    CryptoError::ArchiveTotalBytesCapExceeded {
        total_bytes: total,
        local_cap: cap,
    }
}

pub(super) fn manifest_len_cap_error(len: u64, cap: u32) -> CryptoError {
    CryptoError::ArchiveManifestLenCapExceeded {
        manifest_len: len,
        local_cap: cap,
    }
}

pub(super) fn path_bytes_cap_error(declared_len: u32, cap: u32, path: Option<&str>) -> CryptoError {
    CryptoError::ArchivePathBytesCapExceeded {
        path_bytes: declared_len,
        local_cap: cap,
        path: path.map(sanitize_for_display),
    }
}

pub(super) fn path_depth_cap_error(depth: u32, cap: u32, path_utf8: &str) -> CryptoError {
    CryptoError::ArchivePathDepthCapExceeded {
        depth,
        local_cap: cap,
        path: sanitize_for_display(path_utf8),
    }
}

pub(super) fn archive_ext_cap_error(declared_len: u64, cap: u32) -> CryptoError {
    CryptoError::ArchiveExtLenCapExceeded {
        ext_len: declared_len,
        local_cap: cap,
    }
}

pub(super) fn entry_ext_cap_error(declared_len: u64, cap: u32, path: Option<&str>) -> CryptoError {
    CryptoError::ArchiveEntryExtLenCapExceeded {
        ext_len: declared_len,
        local_cap: cap,
        path: path.map(sanitize_for_display),
    }
}

pub(super) fn total_entry_ext_cap_error(total: u64, cap: u64) -> CryptoError {
    CryptoError::ArchiveTotalEntryExtCapExceeded {
        total_ext_bytes: total,
        local_cap: cap,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        ArchiveLimits, enforce_archive_ext_cap, enforce_entry_ext_cap, enforce_manifest_len_cap,
        enforce_path_bytes_cap, enforce_per_entry_caps, enforce_total_bytes_cap,
        enforce_total_entry_ext_cap,
    };

    /// Every cap this type carries is routed into a test that exercises
    /// it on a real operation, so a new one cannot land untested.
    ///
    /// Rust has no reflection, so a new field does not join those tests
    /// by itself. This destructure stops compiling when one is added,
    /// which is the prompt to route it and then name it here. The five
    /// caps a writer can exercise are measured from both sides by
    /// `tests/config_symmetry.rs`; the four extension caps have no
    /// writer half — every current writer emits an empty region — so
    /// `archive/decode.rs` drives them as reader boundaries instead.
    #[test]
    fn every_cap_is_routed_into_a_test_that_exercises_it() {
        let ArchiveLimits {
            max_entry_count: _,
            max_total_plaintext_bytes: _,
            max_path_depth: _,
            max_path_bytes: _,
            max_manifest_bytes: _,
            max_archive_ext_bytes: _,
            max_entry_ext_bytes: _,
            max_total_entry_ext_bytes: _,
            max_tlv_value_bytes: _,
        } = ArchiveLimits::default();
    }
    /// The `FORMAT.md` §9.12 default table, pinned against the published
    /// constants so a caller reading `ENTRY_COUNT_DEFAULT` and a caller
    /// calling `default()` cannot see different numbers.
    #[test]
    fn defaults_match_spec_values() {
        let l = ArchiveLimits::default();
        assert_eq!(l.max_entry_count, ArchiveLimits::ENTRY_COUNT_DEFAULT);
        assert_eq!(l.max_entry_count, 250_000);
        assert_eq!(
            l.max_total_plaintext_bytes,
            ArchiveLimits::TOTAL_PLAINTEXT_BYTES_DEFAULT
        );
        assert_eq!(l.max_total_plaintext_bytes, 64 * 1024 * 1024 * 1024);
        assert_eq!(l.max_path_depth, ArchiveLimits::PATH_DEPTH_DEFAULT);
        assert_eq!(l.max_path_depth, 64);
        assert_eq!(l.max_path_bytes, ArchiveLimits::PATH_BYTES_DEFAULT);
        assert_eq!(l.max_path_bytes, 4096);
        assert_eq!(l.max_manifest_bytes, ArchiveLimits::MANIFEST_BYTES_DEFAULT);
        assert_eq!(l.max_manifest_bytes, 64 * 1024 * 1024);
        assert_eq!(
            l.max_archive_ext_bytes,
            ArchiveLimits::ARCHIVE_EXT_BYTES_DEFAULT
        );
        assert_eq!(l.max_archive_ext_bytes, 64 * 1024);
        assert_eq!(
            l.max_entry_ext_bytes,
            ArchiveLimits::ENTRY_EXT_BYTES_DEFAULT
        );
        assert_eq!(l.max_entry_ext_bytes, 64 * 1024);
        assert_eq!(
            l.max_total_entry_ext_bytes,
            ArchiveLimits::TOTAL_ENTRY_EXT_BYTES_DEFAULT
        );
        assert_eq!(l.max_total_entry_ext_bytes, 64 * 1024 * 1024);
        assert_eq!(
            l.max_tlv_value_bytes,
            ArchiveLimits::TLV_VALUE_BYTES_DEFAULT
        );
        assert_eq!(l.max_tlv_value_bytes, 16 * 1024 * 1024);
    }

    /// `path_len` is a `u16` on the wire, so the builder clamps rather
    /// than storing a cap the format cannot express. The boundary is
    /// exact: the structural maximum is kept, one above it clamps down.
    #[test]
    fn path_bytes_builder_clamps_at_structural_max() {
        let at = ArchiveLimits::default().max_path_bytes(ArchiveLimits::PATH_BYTES_STRUCTURAL_MAX);
        assert_eq!(at.max_path_bytes, ArchiveLimits::PATH_BYTES_STRUCTURAL_MAX);

        let above = ArchiveLimits::default().max_path_bytes(u32::MAX);
        assert_eq!(
            above.max_path_bytes,
            ArchiveLimits::PATH_BYTES_STRUCTURAL_MAX
        );
    }

    /// Each builder must replace exactly one cap and leave the others at
    /// the receiver's value. Catches an accidental cross-field assignment
    /// if a future refactor reorders or copy-pastes the builder bodies.
    /// `PartialEq` makes each case a single comparison against the
    /// expected result.
    #[test]
    fn builders_replace_only_targeted_field() {
        let base = ArchiveLimits::default();

        let mut want = base;
        want.max_entry_count = 7;
        assert_eq!(base.max_entry_count(7), want);

        let mut want = base;
        want.max_total_plaintext_bytes = 123;
        assert_eq!(base.max_total_plaintext_bytes(123), want);

        let mut want = base;
        want.max_path_depth = 7;
        assert_eq!(base.max_path_depth(7), want);

        let mut want = base;
        want.max_path_bytes = 99;
        assert_eq!(base.max_path_bytes(99), want);

        let mut want = base;
        want.max_manifest_bytes = 42;
        assert_eq!(base.max_manifest_bytes(42), want);

        let mut want = base;
        want.max_archive_ext_bytes = 1234;
        assert_eq!(base.max_archive_ext_bytes(1234), want);

        let mut want = base;
        want.max_entry_ext_bytes = 5678;
        assert_eq!(base.max_entry_ext_bytes(5678), want);

        let mut want = base;
        want.max_total_entry_ext_bytes = 999;
        assert_eq!(base.max_total_entry_ext_bytes(999), want);

        let mut want = base;
        want.max_tlv_value_bytes = 321;
        assert_eq!(base.max_tlv_value_bytes(321), want);
    }

    /// `entry_count > limits.max_entry_count` is `>`, not `>=`. Boundary
    /// is "cap admissible, cap+1 rejected." Pinned because integration
    /// tests fire the cap at well-above-cap values and would not catch a
    /// comparison-operator regression.
    #[test]
    fn enforce_per_entry_caps_entry_count_boundary() {
        let limits = ArchiveLimits::default().max_entry_count(10);
        assert!(enforce_per_entry_caps(10, "a", &limits).is_ok());
        assert!(enforce_per_entry_caps(11, "a", &limits).is_err());
    }

    /// Same boundary for `max_path_depth`: a path with exactly `cap`
    /// components is admissible; `cap + 1` rejected.
    #[test]
    fn enforce_per_entry_caps_depth_boundary() {
        let limits = ArchiveLimits::default().max_path_depth(3);
        assert!(enforce_per_entry_caps(1, "a/b/c", &limits).is_ok());
        assert!(enforce_per_entry_caps(1, "a/b/c/d", &limits).is_err());
    }

    /// Security property: total-byte overflow is rejected even when
    /// the caller raises the cap to `u64::MAX`. The running total is
    /// left unchanged on overflow so callers cannot accidentally keep
    /// using a saturated value as if it were a valid sum.
    #[test]
    fn enforce_total_bytes_cap_rejects_overflow() {
        let limits = ArchiveLimits::default().max_total_plaintext_bytes(u64::MAX);
        let mut total = u64::MAX - 100;
        let result = enforce_total_bytes_cap(200, &mut total, &limits);
        assert!(result.is_err());
        assert_eq!(
            total,
            u64::MAX - 100,
            "total_bytes must not wrap or saturate"
        );
    }

    /// Cap boundary on the bytes side: a running total exactly at the
    /// cap is admissible; one byte over rejects. `>` vs `>=` regression
    /// guard symmetric with the entry-count boundary test above.
    #[test]
    fn enforce_total_bytes_cap_boundary() {
        let limits = ArchiveLimits::default().max_total_plaintext_bytes(100);
        let mut total = 0;
        assert!(enforce_total_bytes_cap(100, &mut total, &limits).is_ok());
        assert_eq!(total, 100);
        assert!(enforce_total_bytes_cap(1, &mut total, &limits).is_err());
        assert_eq!(total, 100);
    }

    /// `>` vs `>=` regression guard for `enforce_path_bytes_cap`.
    /// Path length exactly at cap admissible; cap+1 rejects.
    #[test]
    fn enforce_path_bytes_cap_boundary() {
        let limits = ArchiveLimits::default().max_path_bytes(10);
        assert!(enforce_path_bytes_cap(10, None, &limits).is_ok());
        assert!(enforce_path_bytes_cap(11, None, &limits).is_err());
    }

    /// `>` vs `>=` regression guard for `enforce_manifest_len_cap`.
    /// Manifest length exactly at cap admissible; cap+1 rejects.
    #[test]
    fn enforce_manifest_len_cap_boundary() {
        let limits = ArchiveLimits::default().max_manifest_bytes(100);
        assert!(enforce_manifest_len_cap(100, &limits).is_ok());
        assert!(enforce_manifest_len_cap(101, &limits).is_err());
    }

    /// `>` vs `>=` regression guard for `enforce_archive_ext_cap`.
    /// Archive ext length exactly at cap admissible; cap+1 rejects.
    #[test]
    fn enforce_archive_ext_cap_boundary() {
        let limits = ArchiveLimits::default().max_archive_ext_bytes(100);
        assert!(enforce_archive_ext_cap(100, &limits).is_ok());
        assert!(enforce_archive_ext_cap(101, &limits).is_err());
    }

    /// `>` vs `>=` regression guard for `enforce_entry_ext_cap`.
    /// Entry ext length exactly at cap admissible; cap+1 rejects.
    #[test]
    fn enforce_entry_ext_cap_boundary() {
        let limits = ArchiveLimits::default().max_entry_ext_bytes(100);
        assert!(enforce_entry_ext_cap(100, None, &limits).is_ok());
        assert!(enforce_entry_ext_cap(101, None, &limits).is_err());
    }

    /// `>` vs `>=` regression guard for `enforce_total_entry_ext_cap`.
    /// Running total exactly at cap admissible; cap+1 rejects, total
    /// unchanged on overflow.
    #[test]
    fn enforce_total_entry_ext_cap_boundary() {
        let limits = ArchiveLimits::default().max_total_entry_ext_bytes(100);
        let mut total = 0;
        assert!(enforce_total_entry_ext_cap(100, &mut total, &limits).is_ok());
        assert_eq!(total, 100);
        assert!(enforce_total_entry_ext_cap(1, &mut total, &limits).is_err());
        assert_eq!(total, 100);
    }
}