coordinode-lsm-tree 5.2.1

Embedded LSM-tree storage engine: BuRR filters, zstd dictionary compression, MVCC, range tombstones, merge operators, K/V separation, AES-256-GCM at rest.
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
714
715
716
717
718
719
720
721
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026-present, Structured World Foundation

//! `BlockTransform`: discriminated union of the four valid Block I/O
//! payload transforms.
//!
//! Block I/O has eight valid combinations: four `(compression,
//! encryption)` pipelines, each crossed with the optional
//! Reed-Solomon parity trailer (enabled via the `page_ecc` cargo
//! feature):
//!
//! | Variant                                          | Pipeline                                               |
//! |--------------------------------------------------|-------------------------------------------------------|
//! | [`BlockTransform::Plain`]                        | raw → checksum → disk                                 |
//! | [`BlockTransform::Compressed`]                   | raw → compress → checksum → disk                      |
//! | [`BlockTransform::Encrypted`]                    | raw → encrypt → checksum → disk                       |
//! | [`BlockTransform::CompressedAndEncrypted`]       | raw → compress → encrypt → checksum → disk            |
//! | `BlockTransform::PlainEcc`                       | raw → checksum → ecc parity → disk                    |
//! | `BlockTransform::CompressedEcc`                  | raw → compress → checksum → ecc parity → disk         |
//! | `BlockTransform::EncryptedEcc`                   | raw → encrypt → checksum → ecc parity → disk          |
//! | `BlockTransform::CompressedAndEncryptedEcc`      | raw → compress → encrypt → checksum → ecc parity → disk |
//!
//! The `Ecc` variants are only available when the `page_ecc` cargo
//! feature is enabled; without it, the variant list collapses to
//! the original four. ECC is orthogonal to compression / encryption:
//! parity is computed over the on-disk payload after compression
//! and after encryption, and lives in a trailer that is NOT
//! covered by AEAD authentication and NOT included in the
//! per-block XXH3 over the payload bytes. Tampering with the
//! parity trailer therefore cannot be detected by AEAD or by the
//! block checksum — it only impacts recoverability (a corrupted
//! parity trailer means the codec can't repair payload bit-flips,
//! but the payload itself is still authenticated by its own
//! checksum / AEAD tag and tampering there fails the usual way).
//! In other words: ECC is a best-effort recovery aid for bit-rot
//! in the wire bytes, NOT an integrity primitive.
//!
//! Modelling the four paths as a single enum has two concrete wins
//! over the previous "`(compression, encryption, zstd_dict)` triple"
//! argument shape:
//!
//! 1. **Invalid combinations are unreachable by construction.** The
//!    old API let `ZstdDict { dict_id }` ship without `zstd_dict:
//!    Some(_)`, which only failed at runtime as
//!    `Error::ZstdDictMismatch` deep inside `Block::write_into`.
//!    `CompressionContext`'s fields are now private; the only way
//!    to construct a `ZstdDict` context is via
//!    [`CompressionContext::with_dict`], which takes the dictionary
//!    handle directly and derives the on-disk `dict_id` from
//!    `dict.id()`. The previous "construct a ZstdDict-kind context
//!    without a dict" mistake therefore can't be expressed in the
//!    public API at all. The runtime `ZstdDictMismatch` guards
//!    inside `Block::write_into` / `from_reader` / `from_file`
//!    still execute on every block (they re-check that a dictionary
//!    is attached and that its `id()` matches the on-disk
//!    `dict_id`), but with `BlockTransform` constructed via the
//!    safe API they are defense-in-depth only and cannot fire.
//!    The one remaining path that can legitimately produce
//!    `ZstdDictMismatch` is the legacy-triple helper
//!    [`BlockTransform::from_parts`], which is checked at
//!    construction time (so the error is raised before any
//!    `Block` I/O is attempted) for callers that have not yet
//!    migrated to the typed constructors.
//! 2. **Shrinks the public surface.** `Block::write_into` /
//!    `from_reader` / `from_file` each previously took three
//!    transform-related args; they now take one
//!    `transform: &BlockTransform<'_>`.

#[cfg(zstd_any)]
use crate::compression::ZstdDictionary;
use crate::{CompressionType, encryption::EncryptionProvider};

/// Reed-Solomon / XOR shard layout carried by the `*Ecc` transform
/// variants: `data_shards` data shards plus `parity_shards` parity
/// shards (`parity_shards == 1` is plain-XOR RAID-5).
///
/// Recorded per-SST in the ECC descriptor; the write path takes it from
/// the configured scheme and the read path from the SST's `TableMeta`,
/// so writer and reader always size and recover the parity trailer
/// identically. [`Self::RS_4_2`] is the historical default kept for
/// tests; production picks a low-overhead scheme (XOR single-parity).
///
/// Not feature-gated (a 2-byte POD) so call sites pass it uniformly
/// across the feature matrix; the `*Ecc` variants that store it and
/// the parity codec that consumes it are `page_ecc`-gated.
///
/// Both shard counts are non-zero by construction: the only public
/// constructor is [`Self::try_new`], which rejects a zero in either
/// position (a zero-shard layout is non-recoverable and would serialize
/// to a descriptor that the read path rejects on reopen). The enum is
/// `#[non_exhaustive]`, so a downstream crate cannot bypass that check
/// by building a `Shard { .. }` literal and feeding it to a `*Ecc`
/// transform variant.
// `non_exhaustive` blocks external struct-literal construction of any variant
// (e.g. `EccParams::Shard { data_shards: 0, parity_shards: 0 }`), so the
// non-zero-shard invariant enforced by `try_new` cannot be bypassed by a
// downstream crate reaching the doc-hidden `table::block` module.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum EccParams {
    /// Shard-based parity (XOR single-parity / RAID-5 when `parity_shards ==
    /// 1`, Reed-Solomon when `>= 2`): the block payload is split into
    /// `data_shards` shards and `parity_shards` recovery shards are appended.
    Shard {
        /// Number of data shards the block payload is split into (`>= 1`).
        data_shards: u8,
        /// Number of parity shards (`1` = XOR single-parity / RAID-5; `>= 1`).
        parity_shards: u8,
    },

    /// Per-word Hamming SEC-DED (`crate::secded`, `page_ecc`-gated): one check
    /// byte protects each 8-byte data word, healing a single bit flip and
    /// detecting a double-bit error. No shard layout — the trailer is one byte
    /// per word.
    Secded,
}

impl EccParams {
    /// Legacy fixed RS(4,2) layout (50% overhead). Default for tests
    /// only; never an implicit production default.
    pub const RS_4_2: Self = Self::Shard {
        data_shards: 4,
        parity_shards: 2,
    };

    /// Per-word SEC-DED scheme (12.5% overhead; single-bit correct, double-bit
    /// detect).
    pub const SECDED: Self = Self::Secded;

    /// Builds a shard layout, rejecting a zero count in either position.
    ///
    /// A zero-shard layout has no valid parity trailer, so it is refused
    /// here (the only public construction path) rather than later, deep
    /// in block I/O or on reopen where `TableMeta` parsing would reject
    /// the resulting descriptor.
    ///
    /// # Errors
    ///
    /// Returns [`crate::Error::FeatureUnsupported`] when `data_shards` or
    /// `parity_shards` is zero.
    pub fn try_new(data_shards: u8, parity_shards: u8) -> crate::Result<Self> {
        if data_shards == 0 {
            return Err(crate::Error::FeatureUnsupported("ecc_scheme data_shards=0"));
        }
        if parity_shards == 0 {
            return Err(crate::Error::FeatureUnsupported(
                "ecc_scheme parity_shards=0",
            ));
        }
        Ok(Self::Shard {
            data_shards,
            parity_shards,
        })
    }

    /// Number of data shards the block payload is split into (`>= 1`).
    ///
    /// # Panics
    ///
    /// Panics on a non-shard scheme; callers that may hold one branch on the
    /// variant first.
    #[must_use]
    pub const fn data_shards(self) -> u8 {
        match self {
            Self::Shard { data_shards, .. } => data_shards,
            Self::Secded => panic!("EccParams::data_shards on a non-shard (SEC-DED) scheme"),
        }
    }

    /// Number of parity shards (`1` = XOR single-parity / RAID-5; `>= 1`).
    ///
    /// # Panics
    ///
    /// Panics on a non-shard scheme; callers that may hold one branch on the
    /// variant first.
    #[must_use]
    pub const fn parity_shards(self) -> u8 {
        match self {
            Self::Shard { parity_shards, .. } => parity_shards,
            Self::Secded => panic!("EccParams::parity_shards on a non-shard (SEC-DED) scheme"),
        }
    }

    /// `(data_shards, parity_shards)` as `usize`, for the `crate::ecc`
    /// shard-based encode / recover API.
    ///
    /// # Panics
    ///
    /// Panics on a non-shard scheme; callers branch on the variant first.
    #[must_use]
    pub const fn as_shards(self) -> (usize, usize) {
        match self {
            Self::Shard {
                data_shards,
                parity_shards,
            } => (data_shards as usize, parity_shards as usize),
            Self::Secded => panic!("EccParams::as_shards on a non-shard (SEC-DED) scheme"),
        }
    }
}

/// Codec configuration for the compression step of a block payload.
///
/// Fields are private; the only way to construct a value is via
/// [`Self::new`] (non-dict codecs) or [`Self::with_dict`]
/// (dict-required codec, takes the dictionary handle directly). Two
/// invariants the previous open-fields shape relied on are now
/// enforced by the constructors:
///
/// 1. The kind is never [`CompressionType::None`]: "plain payload"
///    is its own [`BlockTransform::Plain`] / [`BlockTransform::Encrypted`]
///    variant; carrying it as a `CompressionContext` would
///    double-encode the same state. `new()` returns
///    [`crate::Error::FeatureUnsupported`] on `None`.
/// 2. `kind == ZstdDict` is unreachable without an attached
///    dictionary: the `new()` constructor refuses `ZstdDict` (returns
///    [`crate::Error::FeatureUnsupported`], forcing callers through
///    [`Self::with_dict`]), and `with_dict` takes the dictionary by
///    reference and derives `dict_id` from `dict.id()` itself. There
///    is therefore no construction path that yields a `ZstdDict`
///    context without a matching dict. The runtime `ZstdDictMismatch`
///    guards inside `Block::write_into` / `from_reader` / `from_file`
///    still execute on every block, but with `CompressionContext`
///    constructed via the safe API they are defense-in-depth only
///    and cannot fire.
pub struct CompressionContext<'a> {
    kind: CompressionType,

    #[cfg(zstd_any)]
    zstd_dict: Option<&'a ZstdDictionary>,

    #[cfg(not(zstd_any))]
    _lifetime: core::marker::PhantomData<&'a ()>,
}

// `'a` is borrowed by `with_dict` (gated behind `zstd_any`). On
// builds with no zstd backend the borrow drops out, but the
// impl-level lifetime stays so method signatures stay valid across
// the feature matrix without per-method `#[cfg]` gymnastics.
//
// Feature-gated `#[expect]` rather than blanket `#[allow]`: under
// any zstd feature `'a` IS used by `with_dict` and the lint does
// NOT fire — wrapping `#[expect]` in `cfg_attr(not(zstd_any), ..)`
// keeps the stricter "lint expectation will self-expire if the
// underlying code stops triggering" semantics, just feature-scoped
// to the build where the lint actually fires.
#[cfg_attr(
    not(zstd_any),
    expect(
        clippy::elidable_lifetime_names,
        reason = "'a kept for cross-feature-matrix signature stability; \
                  used by with_dict under any zstd feature"
    )
)]
impl<'a> CompressionContext<'a> {
    /// Constructs a [`CompressionContext`] for a non-dict codec
    /// (`Lz4`, `Zstd(level)`).
    ///
    /// Reports invalid `kind`s as [`crate::Error::FeatureUnsupported`]
    /// so a caller that builds a [`CompressionType`] from runtime
    /// config (e.g. parsing a table-config file) sees a typed error
    /// rather than a process panic. Use [`BlockTransform::Plain`] /
    /// [`BlockTransform::Encrypted`] for the no-compression case and
    /// [`Self::with_dict`] for `ZstdDict`.
    ///
    /// # Errors
    ///
    /// - `Error::FeatureUnsupported("compression-context-none")` when
    ///   `kind == CompressionType::None`
    /// - `Error::FeatureUnsupported("compression-context-zstd-dict-via-new")`
    ///   when `kind == CompressionType::ZstdDict { .. }`
    ///
    /// See [`crate::Error::FeatureUnsupported`] for the typed variant.
    pub fn new(kind: CompressionType) -> crate::Result<Self> {
        if kind == CompressionType::None {
            return Err(crate::Error::FeatureUnsupported("compression-context-none"));
        }
        #[cfg(zstd_any)]
        if matches!(kind, CompressionType::ZstdDict { .. }) {
            return Err(crate::Error::FeatureUnsupported(
                "compression-context-zstd-dict-via-new",
            ));
        }
        Ok(Self {
            kind,
            #[cfg(zstd_any)]
            zstd_dict: None,
            #[cfg(not(zstd_any))]
            _lifetime: core::marker::PhantomData,
        })
    }

    /// Constructs a `ZstdDict` context with the matching dictionary
    /// handle attached.
    ///
    /// `dict.id()` is taken as the on-disk `dict_id`, so a mismatch
    /// is unreachable by construction. `level` is the zstd
    /// compression level the writer should use; readers don't
    /// consume it (zstd decompression simply doesn't need the
    /// encoder's chosen level — the level controls only the
    /// encoder's CPU / ratio tradeoff and isn't carried in the zstd
    /// frame at all). It's stored here only to keep the on-disk
    /// [`CompressionType::ZstdDict`] discriminator round-trippable
    /// for writers and metadata that DO need to remember the level
    /// (e.g. the per-table compression policy table).
    #[cfg(zstd_any)]
    #[must_use]
    pub fn with_dict(level: i32, dict: &'a ZstdDictionary) -> Self {
        Self {
            kind: CompressionType::ZstdDict {
                level,
                dict_id: dict.id(),
            },
            zstd_dict: Some(dict),
        }
    }

    /// On-disk codec discriminator.
    #[must_use]
    pub fn kind(&self) -> CompressionType {
        self.kind
    }

    /// Attached zstd dictionary, if any. Always `Some` for
    /// `kind == ZstdDict` (enforced by the constructors), `None`
    /// for non-dict codecs.
    #[cfg(zstd_any)]
    #[must_use]
    pub fn zstd_dict(&self) -> Option<&ZstdDictionary> {
        self.zstd_dict
    }
}

/// Block I/O payload transform: zero, one, or both of compression and
/// encryption.
///
/// Replaces the previous `(compression, encryption, zstd_dict)` argument
/// triple on `Block::write_into` / `Block::from_reader` /
/// `Block::from_file` with a single discriminated union. Each variant
/// pins a different pipeline (see module docs).
pub enum BlockTransform<'a> {
    /// `raw → checksum → disk`. Used by tests that don't exercise
    /// compression or encryption, and by index / filter blocks
    /// configured with `CompressionType::None` on filter-less builds.
    /// Most call sites just reach for the no-allocation
    /// [`BlockTransform::PLAIN`] constant.
    Plain,

    /// `raw → compress → checksum → disk`.
    Compressed(CompressionContext<'a>),

    /// `raw → encrypt → checksum → disk`.
    Encrypted(&'a dyn EncryptionProvider),

    /// `raw → compress → encrypt → checksum → disk`.
    CompressedAndEncrypted(CompressionContext<'a>, &'a dyn EncryptionProvider),

    /// `raw → checksum → ecc parity → disk`. Same as [`Self::Plain`]
    /// but emits a parity trailer after the on-disk payload under the
    /// shard scheme carried in its [`EccParams`] (data + parity shard
    /// counts). On the SST read path, decoded headers zero `block_flags`,
    /// so trailer presence and sizing come from the per-SST descriptor
    /// (`ParsedMeta::ecc_params`) via `Header::on_disk_size_with(ecc)` —
    /// not a header flag; self-describing blocks (Meta / Manifest) keep
    /// the `ECC_PARITY` flag and the fixed RS(4,2) layout. Either way the
    /// reader verifies-and-recovers up to `parity_shards` lost shards
    /// without a separate sidecar.
    #[cfg(feature = "page_ecc")]
    PlainEcc(EccParams),

    /// `raw → compress → checksum → ecc parity → disk`.
    #[cfg(feature = "page_ecc")]
    CompressedEcc(CompressionContext<'a>, EccParams),

    /// `raw → encrypt → checksum → ecc parity → disk`. The parity is
    /// computed over the encrypted ciphertext and stored in a
    /// trailer outside the AEAD-authenticated region. Tampering with
    /// the ciphertext fails AEAD verification on read the usual way;
    /// tampering with the parity trailer specifically is NOT detected
    /// by AEAD (the trailer isn't part of the authenticated payload
    /// and isn't covered by the per-block XXH3 either) — it only
    /// impacts recoverability. ECC is a best-effort recovery aid for
    /// bit-rot, not an integrity primitive on top of AEAD.
    #[cfg(feature = "page_ecc")]
    EncryptedEcc(&'a dyn EncryptionProvider, EccParams),

    /// `raw → compress → encrypt → checksum → ecc parity → disk`.
    #[cfg(feature = "page_ecc")]
    CompressedAndEncryptedEcc(
        CompressionContext<'a>,
        &'a dyn EncryptionProvider,
        EccParams,
    ),
}

impl BlockTransform<'_> {
    /// Borrow-free "no compression, no encryption" transform.
    ///
    /// Tests that don't exercise either transform reach for this
    /// constant instead of constructing
    /// `BlockTransform::Plain` repeatedly; the constant form makes
    /// the "I have no transform context to plumb" intent louder than
    /// a literal `BlockTransform::Plain` would and matches the
    /// idiom from the design discussion in #248.
    pub const PLAIN: Self = Self::Plain;

    /// Codec discriminator for this transform.
    ///
    /// `Plain` / `Encrypted` (and their `Ecc` siblings) map to
    /// [`CompressionType::None`]; `Compressed` / `CompressedAndEncrypted`
    /// (and their `Ecc` siblings) return the inner codec.
    #[must_use]
    pub fn compression(&self) -> CompressionType {
        match self {
            Self::Plain | Self::Encrypted(_) => CompressionType::None,
            Self::Compressed(ctx) | Self::CompressedAndEncrypted(ctx, _) => ctx.kind(),
            #[cfg(feature = "page_ecc")]
            Self::PlainEcc(_) | Self::EncryptedEcc(_, _) => CompressionType::None,
            #[cfg(feature = "page_ecc")]
            Self::CompressedEcc(ctx, _) | Self::CompressedAndEncryptedEcc(ctx, _, _) => ctx.kind(),
        }
    }

    /// Optional zstd dictionary reference for this transform.
    ///
    /// Only `Compressed` / `CompressedAndEncrypted` (and their `Ecc`
    /// siblings) variants can carry one; the other variants return
    /// `None`.
    #[cfg(zstd_any)]
    #[must_use]
    pub fn zstd_dict(&self) -> Option<&ZstdDictionary> {
        match self {
            Self::Plain | Self::Encrypted(_) => None,
            Self::Compressed(ctx) | Self::CompressedAndEncrypted(ctx, _) => ctx.zstd_dict(),
            #[cfg(feature = "page_ecc")]
            Self::PlainEcc(_) | Self::EncryptedEcc(_, _) => None,
            #[cfg(feature = "page_ecc")]
            Self::CompressedEcc(ctx, _) | Self::CompressedAndEncryptedEcc(ctx, _, _) => {
                ctx.zstd_dict()
            }
        }
    }

    /// Optional encryption provider for this transform.
    ///
    /// Only `Encrypted` / `CompressedAndEncrypted` (and their `Ecc`
    /// siblings) variants carry one; the other variants return `None`.
    #[must_use]
    pub fn encryption(&self) -> Option<&dyn EncryptionProvider> {
        match self {
            Self::Plain | Self::Compressed(_) => None,
            Self::Encrypted(enc) | Self::CompressedAndEncrypted(_, enc) => Some(*enc),
            #[cfg(feature = "page_ecc")]
            Self::PlainEcc(_) | Self::CompressedEcc(_, _) => None,
            #[cfg(feature = "page_ecc")]
            Self::EncryptedEcc(enc, _) | Self::CompressedAndEncryptedEcc(_, enc, _) => Some(*enc),
        }
    }

    /// Whether this transform emits a Reed-Solomon parity trailer
    /// after the on-disk payload. Always `false` when the
    /// `page_ecc` feature is disabled (the `Ecc` variants don't
    /// exist in that build, so the match degenerates to a single
    /// arm and the compiler folds the call to a constant).
    #[must_use]
    pub fn page_ecc(&self) -> bool {
        match self {
            Self::Plain
            | Self::Compressed(_)
            | Self::Encrypted(_)
            | Self::CompressedAndEncrypted(_, _) => false,
            #[cfg(feature = "page_ecc")]
            Self::PlainEcc(_)
            | Self::CompressedEcc(_, _)
            | Self::EncryptedEcc(_, _)
            | Self::CompressedAndEncryptedEcc(_, _, _) => true,
        }
    }

    /// Shard layout for the parity trailer when this transform emits
    /// one, else `None`. The write path sizes the trailer from this and
    /// the read path recovers with it (sourced per-SST so both agree).
    ///
    /// Not feature-gated: without `page_ecc` there are no `*Ecc`
    /// variants, so this is always `None` — call sites stay uniform
    /// across the feature matrix.
    #[must_use]
    pub fn ecc_params(&self) -> Option<EccParams> {
        match self {
            Self::Plain
            | Self::Compressed(_)
            | Self::Encrypted(_)
            | Self::CompressedAndEncrypted(_, _) => None,
            #[cfg(feature = "page_ecc")]
            Self::PlainEcc(p)
            | Self::CompressedEcc(_, p)
            | Self::EncryptedEcc(_, p)
            | Self::CompressedAndEncryptedEcc(_, _, p) => Some(*p),
        }
    }

    /// Returns the matching `*Ecc` variant of this transform when
    /// the `page_ecc` cargo feature is enabled, or the transform
    /// unchanged when the feature is off.
    ///
    /// Lets writer call sites stay compact when they need to
    /// conditionally emit a parity trailer based on a runtime flag
    /// (`Config::page_ecc(true)`):
    ///
    /// ```text
    /// let transform = BlockTransform::from_parts(...)?;
    /// let transform = match ecc_params {
    ///     Some(params) => transform.with_ecc(params),
    ///     None => transform,
    /// };
    /// ```
    ///
    /// On builds without the `page_ecc` feature the Ecc variants
    /// don't exist and this method becomes the identity function —
    /// the compiler folds it out at the call site so the
    /// runtime-flag branch is dead code.
    #[cfg(feature = "page_ecc")]
    #[must_use]
    pub fn with_ecc(self, params: EccParams) -> Self {
        // Non-Ecc variants gain the trailer; already-Ecc variants
        // re-stamp the params (same target variant either way).
        match self {
            Self::Plain | Self::PlainEcc(_) => Self::PlainEcc(params),
            Self::Compressed(ctx) | Self::CompressedEcc(ctx, _) => Self::CompressedEcc(ctx, params),
            Self::Encrypted(enc) | Self::EncryptedEcc(enc, _) => Self::EncryptedEcc(enc, params),
            Self::CompressedAndEncrypted(ctx, enc)
            | Self::CompressedAndEncryptedEcc(ctx, enc, _) => {
                Self::CompressedAndEncryptedEcc(ctx, enc, params)
            }
        }
    }

    /// Identity on builds without the `page_ecc` feature: the `Ecc`
    /// variants don't exist, so callers' `if ecc { t.with_ecc(..) }`
    /// branch is dead and folds out. Takes [`EccParams`] (which is not
    /// feature-gated) so call sites compile across the feature matrix
    /// without per-call `#[cfg]`.
    #[cfg(not(feature = "page_ecc"))]
    #[must_use]
    pub fn with_ecc(self, _params: EccParams) -> Self {
        self
    }
}

impl<'a> BlockTransform<'a> {
    /// Builds a `BlockTransform` from the legacy `(compression,
    /// encryption, zstd_dict)` argument triple.
    ///
    /// Used by intermediate functions that haven't yet been
    /// refactored to receive a `BlockTransform` directly; the type
    /// safety win lives at the call sites that construct
    /// `BlockTransform` from local context (writers), not at every
    /// generic load helper that just forwards what its caller gave
    /// it. Returns an error for the same
    /// `CompressionType::ZstdDict` + `zstd_dict.is_none()` mismatch
    /// the old API surfaced at runtime in `Block::write_into`,
    /// centralised here so every entry point gets the same
    /// diagnostic.
    ///
    /// # Errors
    ///
    /// Returns [`crate::Error::ZstdDictMismatch`] when `compression`
    /// is [`CompressionType::ZstdDict`] but `zstd_dict` is `None` or
    /// has a non-matching dictionary id.
    pub fn from_parts(
        compression: CompressionType,
        encryption: Option<&'a dyn EncryptionProvider>,
        #[cfg(zstd_any)] zstd_dict: Option<&'a ZstdDictionary>,
    ) -> crate::Result<Self> {
        // CompressionType::None → no compression pipeline; pick
        // Plain / Encrypted based on whether an encryption provider
        // is present.
        if compression == CompressionType::None {
            return Ok(match encryption {
                Some(enc) => Self::Encrypted(enc),
                None => Self::Plain,
            });
        }

        // Compressed path. The legacy triple may pass `ZstdDict` with
        // a separate `zstd_dict` argument; that's the only runtime
        // check `from_parts` needs to do (downstream `with_dict`
        // can't fail by construction). Non-dict codecs go through
        // `new()` which is total.
        #[cfg(zstd_any)]
        let ctx = if let CompressionType::ZstdDict { level, dict_id } = compression {
            let dict = zstd_dict.ok_or(crate::Error::ZstdDictMismatch {
                expected: dict_id,
                got: None,
            })?;
            if dict.id() != dict_id {
                return Err(crate::Error::ZstdDictMismatch {
                    expected: dict_id,
                    got: Some(dict.id()),
                });
            }
            CompressionContext::with_dict(level, dict)
        } else {
            // Non-dict codecs ignore the zstd_dict slot, matching the
            // previous API.
            let _ = zstd_dict;
            CompressionContext::new(compression)?
        };

        #[cfg(not(zstd_any))]
        let ctx = CompressionContext::new(compression)?;

        Ok(match encryption {
            Some(enc) => Self::CompressedAndEncrypted(ctx, enc),
            None => Self::Compressed(ctx),
        })
    }
}

#[cfg(test)]
#[expect(
    clippy::expect_used,
    reason = "tests panic on the unhappy paths to surface failures loudly"
)]
mod tests {
    use super::*;

    #[test]
    fn plain_transform_reports_no_compression_no_encryption_no_ecc() {
        let t = BlockTransform::Plain;
        assert_eq!(t.compression(), CompressionType::None);
        assert!(t.encryption().is_none());
        assert!(!t.page_ecc());
    }

    #[test]
    fn plain_constant_matches_plain_variant() {
        let t = BlockTransform::PLAIN;
        assert!(matches!(t, BlockTransform::Plain));
        assert!(!t.page_ecc());
    }

    #[cfg(feature = "page_ecc")]
    #[test]
    fn plain_ecc_variant_reports_ecc_enabled_no_other_transform() {
        let t = BlockTransform::PlainEcc(EccParams::RS_4_2);
        assert_eq!(t.compression(), CompressionType::None);
        assert!(t.encryption().is_none());
        assert!(t.page_ecc());
    }

    #[cfg(all(feature = "page_ecc", feature = "lz4"))]
    #[test]
    fn compressed_ecc_carries_compression_kind_and_reports_ecc() {
        let Ok(ctx) = CompressionContext::new(CompressionType::Lz4) else {
            panic!("Lz4 ctx construction is total");
        };
        let t = BlockTransform::CompressedEcc(ctx, EccParams::RS_4_2);
        assert_eq!(t.compression(), CompressionType::Lz4);
        assert!(t.encryption().is_none());
        assert!(t.page_ecc());
    }

    #[test]
    fn eccparams_try_new_rejects_zero_shards() {
        // Zero in either position has no valid parity layout, so the only
        // public constructor must refuse it (the invariant the private
        // fields exist to protect).
        assert!(matches!(
            EccParams::try_new(0, 2),
            Err(crate::Error::FeatureUnsupported(_))
        ));
        assert!(matches!(
            EccParams::try_new(8, 0),
            Err(crate::Error::FeatureUnsupported(_))
        ));
        let ok = EccParams::try_new(8, 2).expect("non-zero shards are accepted");
        assert_eq!((ok.data_shards(), ok.parity_shards()), (8, 2));
        assert_eq!(ok.as_shards(), (8, 2));
    }

    #[cfg(feature = "page_ecc")]
    #[test]
    fn with_ecc_upgrades_plain_to_plain_ecc() {
        let p = EccParams::try_new(8, 2).expect("valid shards");
        let t = BlockTransform::Plain.with_ecc(p);
        assert!(matches!(t, BlockTransform::PlainEcc(_)));
        assert_eq!(t.ecc_params(), Some(p));
        assert_eq!(t.compression(), CompressionType::None);
        assert!(t.encryption().is_none());
        // Re-stamping an already-Ecc variant replaces the params.
        let p2 = EccParams::try_new(4, 2).expect("valid shards");
        assert_eq!(t.with_ecc(p2).ecc_params(), Some(p2));
    }

    #[cfg(all(feature = "page_ecc", feature = "encryption"))]
    #[test]
    fn with_ecc_upgrades_encrypted_variants() {
        let p = EccParams::try_new(8, 2).expect("valid shards");
        let enc = crate::encryption::Aes256GcmProvider::new(&[0x11; 32]);

        let t = BlockTransform::Encrypted(&enc).with_ecc(p);
        assert!(matches!(t, BlockTransform::EncryptedEcc(_, _)));
        assert_eq!(t.ecc_params(), Some(p));
        assert!(t.encryption().is_some());
        assert_eq!(t.compression(), CompressionType::None);

        #[cfg(feature = "lz4")]
        {
            let ctx = CompressionContext::new(CompressionType::Lz4).expect("lz4 ctx");
            let t = BlockTransform::CompressedAndEncrypted(ctx, &enc).with_ecc(p);
            assert!(matches!(
                t,
                BlockTransform::CompressedAndEncryptedEcc(_, _, _)
            ));
            assert_eq!(t.ecc_params(), Some(p));
            assert!(t.encryption().is_some());
            assert_eq!(t.compression(), CompressionType::Lz4);
        }
    }
}