djogi 0.1.0-alpha.17

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
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
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
// Library APIs consumed by macro-generated code at consumer sites, not
// within djogi itself — suppress expected dead_code warnings.
#![allow(dead_code)]

//! AES-256-GCM field codec for encrypted-at-rest protected fields.
//!
//! Provides authenticated encryption (confidentiality + integrity) for
//! `String` fields, storing ciphertext as `Vec<u8>` in Postgres `BYTEA` columns.
//!
//! ## Ciphertext layout
//!
//! ```text
//! +---------+-----------+--------+------------------+
//! | version | key_index | nonce  | ciphertext + tag |
//! | 1 B     | 1 B       | 12 B   | variable length  |
//! +---------+-----------+--------+------------------+
//! ```
//!
//! Total storage overhead: `plaintext.len() + 30` bytes (1 version byte +
//! 1 key-index byte + 12-byte nonce + 16-byte GCM tag). The minimum valid
//! ciphertext length is 30 bytes (empty plaintext).
//!
//! - **version** (`0x01`): future layouts increment this byte; an unrecognized
//!   value is rejected with [`CodecError::UnknownVersion`].
//! - **key_index** (`0..=31`): the ring position the ciphertext was encrypted
//!   under; an out-of-range index is rejected with
//!   [`CodecError::UnknownKeyIndex`].
//!
//! ## Key management (key ring)
//!
//! Keys come from the `DJOGI_FIELD_CODEC_KEY_0` through
//! `DJOGI_FIELD_CODEC_KEY_31` environment variables — each exactly 64 lowercase
//! hex characters (32 bytes / 256 bits). `_0` is always required; the ring must
//! have no gaps (if index N is present, every index `0..=N` must be present);
//! the highest present index is the *active* index used for new encryptions.
//! The validated ring is cached in a `OnceLock<Vec<[u8; 32]>>` after first
//! successful parse, so subsequent encode/decode calls skip the env-var reads.
//! Startup validation calls [`load_ring`] before any CRUD operation to populate
//! the cache and close the decode-time side-channel (a populated cache means
//! `load_ring` can only return `Ok`, never `RingEmpty` / `MissingKey`).
//!
//! ## Per-(model, field) subkey derivation
//!
//! Ring entries are never used directly as AES keys. Each encode/decode derives
//! a per-(model, field) subkey via HKDF-SHA256 (IKM = the ring entry at the
//! recorded index, no salt, info = `b"djogi:aes256_gcm_v1\x00{model}\x00{field}"`).
//! Compromise of one field's ciphertext does not expose other fields.
//!
//! ## AAD binding
//!
//! Each encode/decode call constructs AAD as `format!("{}\x00{}", model, field)`,
//! binding the ciphertext to its model and field context. Relocating ciphertext
//! to a different field or model causes authentication failure ([`CodecError::AeadError`])
//! rather than silent decryption with wrong context. The HKDF info string binds
//! the same context into the subkey, so a relocated ciphertext fails on two
//! independent layers: subkey mismatch and AAD mismatch.

use std::sync::OnceLock;

use aes_gcm::aead::{Aead, Nonce, Payload};
use aes_gcm::{Aes256Gcm, KeyInit};

use super::{CodecError, FieldCodec, MissingKeyKind};
use crate::migrate::OnlineSafetyClassification;

/// The always-required base ring entry. The validator scans the full
/// `DJOGI_FIELD_CODEC_KEY_0..=31` family; this anchor names the base entry for
/// the startup error and is the `env_var` the macro passes to
/// `FieldCodecStartupRequirement::const_new`.
#[doc(hidden)]
pub const ENV_VAR: &str = "DJOGI_FIELD_CODEC_KEY_0";

/// Cached key ring, indexed by ring position. Populated by [`load_ring`] at
/// startup validation time (or on first encode/decode call if startup
/// validation is bypassed). Once set, immutable for the process lifetime —
/// subsequent reads of the `DJOGI_FIELD_CODEC_KEY_{N}` variables are ignored.
static CODEC_RING: OnceLock<Vec<[u8; 32]>> = OnceLock::new();

/// Mutex serializing env-var mutation across the full
/// `DJOGI_FIELD_CODEC_KEY_{0..=31}` family in tests so concurrent test runs do
/// not interfere with each other's ring state.
/// Only active under `#[cfg(test)]`. Production code never uses this mutex.
#[cfg(all(test, feature = "aes-codec"))]
pub(crate) static TEST_CODEC_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Scan `DJOGI_FIELD_CODEC_KEY_0` through `DJOGI_FIELD_CODEC_KEY_31`, validate
/// every present entry, and cache the ring in [`CODEC_RING`].
///
/// On first call, reads the env vars, validates each entry is 64 lowercase hex
/// characters (ASCII digits or `a`–`f`) decoding to exactly 32 bytes, verifies
/// the no-gap invariant (if index N is present, every `0..=N` must be present),
/// and stores the ring in the `OnceLock` via `set()`. On subsequent calls,
/// returns `Ok(())` without reading the environment again.
///
/// Startup validation MUST call this function (not an independent env-var check)
/// so that the `OnceLock` is populated before any CRUD call reaches the codec.
/// This guarantees the side-channel prevention property: if startup succeeds,
/// every subsequent `load_ring()` returns `Ok` — decode-time `RingEmpty` /
/// `MissingKey` is impossible.
///
/// Marked `pub` (not private) so macro-generated `inventory::submit!` blocks in
/// adopter crates can reference this function as the `validate` fn-pointer on
/// [`super::FieldCodecStartupRequirement`]. The `#[doc(hidden)]` attribute keeps
/// it out of the public API docs.
///
/// # Errors
/// - [`CodecError::RingEmpty`] when no `DJOGI_FIELD_CODEC_KEY_*` variable is set.
/// - [`CodecError::MissingKey`] with [`MissingKeyKind::Gap`] when a lower index
///   is absent while a higher one is present (names the missing index).
/// - [`CodecError::MissingKey`] with [`MissingKeyKind::Malformed`] when an entry
///   is not 64 lowercase hex characters / does not decode to 32 bytes.
#[doc(hidden)]
pub fn load_ring() -> Result<(), CodecError> {
    // Fast path: already cached from startup or a prior call.
    if CODEC_RING.get().is_some() {
        return Ok(());
    }

    // Slow path: scan the indexed env vars and cache.
    let mut ring: Vec<[u8; 32]> = Vec::new();
    for index in 0u8..=31 {
        let name = format!("DJOGI_FIELD_CODEC_KEY_{index}");
        match std::env::var(&name) {
            Ok(raw) => {
                if ring.len() != index as usize {
                    // A lower index is missing — gap in the ring. The missing
                    // index is the next position we expected to fill
                    // (`ring.len()`), not the present index we are looking at.
                    return Err(CodecError::MissingKey {
                        index: ring.len() as u8,
                        kind: MissingKeyKind::Gap,
                    });
                }
                // Reject any non-lowercase-hex or non-32-byte entry, naming this
                // index. Lowercase is enforced explicitly (uppercase `A`–`F` is
                // rejected) to match the DJOGI_PRESENTATION_HMAC_KEY validator:
                // 64 lowercase hex characters, i.e. ASCII digits or `a`–`f`.
                // `hex::decode` alone would silently accept uppercase, so the
                // lowercase-only invariant is checked at the byte level first.
                // This is a byte-level predicate, not a regex (per the project's
                // no-regex rule).
                let is_lower_hex = raw.len() == 64
                    && raw
                        .bytes()
                        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
                if !is_lower_hex {
                    return Err(CodecError::MissingKey {
                        index,
                        kind: MissingKeyKind::Malformed,
                    });
                }
                let bytes = hex::decode(&raw).map_err(|_| CodecError::MissingKey {
                    index,
                    kind: MissingKeyKind::Malformed,
                })?;
                let arr: [u8; 32] = bytes.try_into().map_err(|_| CodecError::MissingKey {
                    index,
                    kind: MissingKeyKind::Malformed,
                })?;
                ring.push(arr);
            }
            // Absent is fine unless a higher index turns out to be present
            // (caught by the gap check above on the next present entry).
            Err(_) => continue,
        }
    }
    if ring.is_empty() {
        return Err(CodecError::RingEmpty);
    }

    // Set wins-once: if a concurrent call already set the ring, the duplicate is
    // silently ignored — the first successful parse is stable.
    let _ = CODEC_RING.set(ring);
    Ok(())
}

/// Derive the per-(model, field) AES-256 subkey from a ring entry via
/// HKDF-SHA256. The info string provides domain separation: the same ring entry
/// produces a different AES key for every (model, field) pair.
fn derive_subkey(ikm: &[u8; 32], model: &str, field: &str) -> [u8; 32] {
    let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, ikm); // no salt
    let mut info = Vec::with_capacity(20 + model.len() + 1 + field.len());
    info.extend_from_slice(b"djogi:aes256_gcm_v1\x00");
    info.extend_from_slice(model.as_bytes());
    info.push(0);
    info.extend_from_slice(field.as_bytes());
    let mut okm = [0u8; 32];
    // `expand` only errors when the requested length exceeds `255 * 32` bytes;
    // 32 is always valid, so this is an infallible-in-practice invariant on a
    // framework-controlled length, not a panic on untrusted input.
    hk.expand(&info, &mut okm)
        .expect("32 bytes is a valid HKDF-SHA256 output length");
    okm
}

/// Generate a fresh 96-bit (12-byte) AES-GCM nonce from the OS CSPRNG.
/// Fails only if `getrandom` itself fails (host entropy source unavailable),
/// surfaced as [`CodecError::RngFailure`] via the `From<getrandom::Error>` impl
/// rather than a panic.
fn generate_nonce() -> Result<[u8; 12], CodecError> {
    let mut nonce = [0u8; 12];
    getrandom::fill(&mut nonce)?; // getrandom 0.3: `fill`; `?` via From<getrandom::Error>
    Ok(nonce)
}

/// AES-256-GCM field codec.
///
/// Encrypts `String` fields into versioned `Vec<u8>` ciphertext using
/// AES-256-GCM with per-(model, field) HKDF-derived subkeys. The stored blob is
/// `[version (1 B) | key_index (1 B) | nonce (12 B) | ciphertext + tag]`; AAD is
/// bound as `model\x00field` to prevent ciphertext-relocation attacks.
///
/// Adopters reach this codec only via the
/// `#[field(protected(codec = "aes256_gcm_v1"))]` attribute; the macro resolves
/// the type and emits the encode/decode calls. The column is `BYTEA`.
pub struct Aes256GcmV1;

impl FieldCodec for Aes256GcmV1 {
    const ID: &'static str = "aes256_gcm_v1";

    type Decoded = String;
    type Encoded = Vec<u8>;
    type Error = CodecError;

    fn encode(
        model: &'static str,
        field: &'static str,
        value: &String,
    ) -> Result<Vec<u8>, CodecError> {
        load_ring()?; // no-op fast path once cached
        let ring = CODEC_RING.get().ok_or(CodecError::RingEmpty)?;
        let active_index = ring.len() - 1; // highest present index
        let subkey = derive_subkey(&ring[active_index], model, field);
        let nonce_bytes = generate_nonce()?;
        let nonce = Nonce::<Aes256Gcm>::from_slice(&nonce_bytes);
        let aad = format!("{model}\x00{field}");
        // A 32-byte slice is always a valid AES-256 key, so this is an
        // infallible-invariant assertion on a framework-controlled input, not a
        // panic on untrusted data (same posture as the HMAC codec precedent).
        let aead =
            Aes256Gcm::new_from_slice(&subkey).expect("32-byte slice is a valid AES-256 key");
        let ciphertext = aead.encrypt(
            nonce,
            Payload {
                msg: value.as_bytes(),
                aad: aad.as_bytes(),
            },
        )?; // From<aes_gcm::Error> — propagate rather than panic
        // [version (1 B) | key_index (1 B) | nonce (12 B) | ciphertext + tag]
        let mut out = Vec::with_capacity(2 + 12 + ciphertext.len());
        out.push(0x01); // version
        out.push(active_index as u8); // key_index
        out.extend_from_slice(&nonce_bytes);
        out.extend_from_slice(&ciphertext);
        Ok(out)
    }

    fn decode(
        model: &'static str,
        field: &'static str,
        stored: &Vec<u8>,
    ) -> Result<String, CodecError> {
        // Minimum valid blob: 30 bytes (1 version + 1 key_index + 12 nonce +
        // 16 tag, even for empty plaintext).
        if stored.len() < 30 {
            return Err(CodecError::CiphertextTooShort);
        }
        let version = stored[0];
        if version != 0x01 {
            return Err(CodecError::UnknownVersion(version));
        }
        load_ring()?; // no-op fast path once cached
        let ring = CODEC_RING.get().ok_or(CodecError::RingEmpty)?;
        let key_index = stored[1] as usize;
        if key_index >= ring.len() {
            // Capture ring length at decode time so the error distinguishes
            // "never present" from "retired".
            return Err(CodecError::UnknownKeyIndex {
                index: stored[1],
                ring_len: ring.len() as u8,
            });
        }
        let nonce = Nonce::<Aes256Gcm>::from_slice(&stored[2..14]);
        let ciphertext = &stored[14..];
        let subkey = derive_subkey(&ring[key_index], model, field);
        let aad = format!("{model}\x00{field}");
        let aead =
            Aes256Gcm::new_from_slice(&subkey).expect("32-byte slice is a valid AES-256 key");
        let plaintext = aead.decrypt(
            nonce,
            Payload {
                msg: ciphertext,
                aad: aad.as_bytes(),
            },
        )?; // From<aes_gcm::Error>
        Ok(String::from_utf8(plaintext)?) // From<FromUtf8Error>
    }

    fn classify_transition<Other: FieldCodec>() -> OnlineSafetyClassification {
        if Other::ID == Self::ID {
            // Same codec — no-op transition.
            OnlineSafetyClassification::OnlineSafe
        } else {
            // Codec ↔ different codec: standard expand-contract migration
            // pattern. The plaintext → encrypted case is handled at the
            // descriptor/classifier level (where `codec` is `None`), not through
            // a FieldCodec type — this method is type-to-type and never sees
            // plaintext.
            OnlineSafetyClassification::ExpandContract
        }
    }
}

/// Run `f` with a specific key ring active. Acquires [`TEST_CODEC_ENV_MUTEX`] to
/// serialize env-var mutation across concurrent tests. Sets
/// `DJOGI_FIELD_CODEC_KEY_{i}` for each provided hex key (index = slice
/// position), clears the remaining indexed variables, calls [`load_ring`] to
/// populate the `OnceLock`, runs `f`, then restores the previous env state.
///
/// **Single-`OnceLock` honesty:** the ring cache is single-set per process. If a
/// prior test already populated [`CODEC_RING`], `load_ring` returns `Ok`
/// immediately (fast path) and this call does NOT re-populate the ring. Tests
/// that need a specific cold ring must be the first to touch it (mark them
/// `#[serial_test::serial]`) or run in a dedicated test binary (own process =
/// fresh `OnceLock`).
#[cfg(all(test, feature = "aes-codec"))]
pub(crate) fn test_with_codec_ring(keys_hex: &[&str], f: impl FnOnce()) {
    let _lock = TEST_CODEC_ENV_MUTEX.lock().unwrap();

    // Save previous env var state for all 32 indexed variables.
    let prev: Vec<Option<String>> = (0..32)
        .map(|i| std::env::var(format!("DJOGI_FIELD_CODEC_KEY_{i}")).ok())
        .collect();
    for i in 0..32 {
        let name = format!("DJOGI_FIELD_CODEC_KEY_{i}");
        match keys_hex.get(i) {
            Some(k) => unsafe { std::env::set_var(&name, k) },
            None => unsafe { std::env::remove_var(&name) },
        }
    }

    // Populate the OnceLock with the test ring (no-op if already set).
    let _ = load_ring();

    f();

    // Restore previous env var state via the guard pattern.
    for (i, p) in prev.into_iter().enumerate() {
        let name = format!("DJOGI_FIELD_CODEC_KEY_{i}");
        match p {
            Some(v) => unsafe { std::env::set_var(&name, &v) },
            None => unsafe { std::env::remove_var(&name) },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error;

    /// Test key at ring index 0: 32 bytes, mostly zeros (deterministic).
    const TEST_KEY_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
    /// Distinct second test key (ring index 1).
    const TEST_KEY_HEX_B: &str = "0000000000000000000000000000000000000000000000000000000000000002";

    #[test]
    fn encode_decode_round_trip() {
        test_with_codec_ring(&[TEST_KEY_HEX], || {
            let original = String::from("djogi encrypted-at-rest round-trip test");
            let encoded = Aes256GcmV1::encode("TestModel", "secret_field", &original)
                .expect("encode should succeed with valid ring");

            // Overhead is 30 bytes (1 version + 1 key_index + 12 nonce + 16 tag).
            assert_eq!(encoded.len(), original.len() + 30);

            let decoded = Aes256GcmV1::decode("TestModel", "secret_field", &encoded)
                .expect("decode should succeed with matching ring and AAD");

            assert_eq!(decoded, original);
        });
    }

    #[test]
    fn encode_produces_different_ciphertext_each_time() {
        // Nonce is randomly generated per-encryption, so two encodes of the same
        // plaintext produce different ciphertexts.
        test_with_codec_ring(&[TEST_KEY_HEX], || {
            let original = String::from("same plaintext");
            let encoded1 = Aes256GcmV1::encode("TestModel", "field", &original).unwrap();
            let encoded2 = Aes256GcmV1::encode("TestModel", "field", &original).unwrap();

            assert_ne!(
                encoded1, encoded2,
                "two encodes of same plaintext should differ (random nonce)"
            );

            assert_eq!(
                Aes256GcmV1::decode("TestModel", "field", &encoded1).unwrap(),
                original
            );
            assert_eq!(
                Aes256GcmV1::decode("TestModel", "field", &encoded2).unwrap(),
                original
            );
        });
    }

    #[test]
    fn aad_mismatch_returns_aead_error() {
        // Encrypting with one (model, field) pair and decrypting with another
        // fails authentication: both the AAD and the HKDF subkey differ.
        test_with_codec_ring(&[TEST_KEY_HEX], || {
            let original = String::from("aad binding test");
            let encoded = Aes256GcmV1::encode("ModelA", "field_a", &original).unwrap();

            let err = Aes256GcmV1::decode("ModelB", "field_a", &encoded).unwrap_err();
            assert!(matches!(err, CodecError::AeadError(_)));

            let err = Aes256GcmV1::decode("ModelA", "field_b", &encoded).unwrap_err();
            assert!(matches!(err, CodecError::AeadError(_)));

            assert_eq!(
                Aes256GcmV1::decode("ModelA", "field_a", &encoded).unwrap(),
                original
            );
        });
    }

    #[test]
    fn ciphertext_too_short_returns_error() {
        test_with_codec_ring(&[TEST_KEY_HEX], || {
            let short = vec![0u8; 10]; // less than the 30-byte minimum
            let err = Aes256GcmV1::decode("Model", "field", &short).unwrap_err();
            assert!(matches!(err, CodecError::CiphertextTooShort));

            let msg = err.to_string();
            assert!(
                msg.contains("30"),
                "message should mention the 30-byte minimum: {msg}"
            );
        });
    }

    #[test]
    fn unknown_version_decode() {
        test_with_codec_ring(&[TEST_KEY_HEX], || {
            // 30-byte blob with an unrecognized version byte 0x02. The version
            // check runs before any ring access, so the ring contents are
            // irrelevant here.
            let mut blob = vec![0u8; 30];
            blob[0] = 0x02;
            let err = Aes256GcmV1::decode("Model", "field", &blob).unwrap_err();
            assert!(
                matches!(err, CodecError::UnknownVersion(0x02)),
                "got: {err:?}"
            );
        });
    }

    #[test]
    fn unknown_key_index_decode() {
        // Encode under a 2-entry ring, then hand-edit the key_index byte to 5
        // (out of range), and decode → UnknownKeyIndex { index: 5, ring_len: 2 }.
        test_with_codec_ring(&[TEST_KEY_HEX, TEST_KEY_HEX_B], || {
            let original = String::from("rotate me");
            let mut encoded = Aes256GcmV1::encode("Model", "field", &original).unwrap();
            encoded[1] = 5; // forge an out-of-range key index
            let err = Aes256GcmV1::decode("Model", "field", &encoded).unwrap_err();
            match err {
                CodecError::UnknownKeyIndex { index, ring_len } => {
                    assert_eq!(index, 5);
                    // ring_len may be >= 2 depending on whether this process's
                    // OnceLock was set by an earlier serial test; assert the
                    // Display shape against the captured value.
                    assert_eq!(
                        err.to_string(),
                        format!("key index 5 not in ring of length {ring_len}")
                    );
                }
                other => panic!("expected UnknownKeyIndex, got: {other:?}"),
            }
        });
    }

    #[test]
    fn empty_string_round_trip() {
        test_with_codec_ring(&[TEST_KEY_HEX], || {
            let original = String::new();
            let encoded = Aes256GcmV1::encode("Model", "field", &original).unwrap();
            // Minimum blob: 30 bytes for empty plaintext.
            assert_eq!(encoded.len(), 30);

            let decoded = Aes256GcmV1::decode("Model", "field", &encoded).unwrap();
            assert_eq!(decoded, original);
        });
    }

    #[test]
    fn hkdf_domain_separation() {
        // Same ring entry, encode under ("ModelA", "f"); decoding A's blob under
        // ("ModelB", "f") fails because the HKDF subkey (and AAD) differ.
        test_with_codec_ring(&[TEST_KEY_HEX], || {
            let original = String::from("domain separation");
            let blob_a = Aes256GcmV1::encode("ModelA", "f", &original).unwrap();
            let err = Aes256GcmV1::decode("ModelB", "f", &blob_a).unwrap_err();
            assert!(matches!(err, CodecError::AeadError(_)), "got: {err:?}");
        });
    }

    #[test]
    fn aead_error_display_safety() {
        // The aes-gcm crate uses an opaque `aead::Error` whose Display is
        // "generic AEAD error" — no key bytes, nonce values, or ciphertext.
        let aead_err = CodecError::AeadError(aes_gcm::aead::Error);
        let msg = aead_err.to_string();
        assert_eq!(msg, "generic AEAD error");
        assert!(!msg.contains("0x"));
        assert!(!msg.contains("key"));
        assert!(!msg.contains("nonce"));
    }

    #[test]
    fn codec_error_source_chaining() {
        // Utf8Error and RngFailure chain a source; the structural variants do
        // not, and the opaque AeadError cannot.
        let utf8_err = String::from_utf8(vec![0xff]).unwrap_err();
        assert!(CodecError::Utf8Error(utf8_err).source().is_some());

        assert!(
            CodecError::AeadError(aes_gcm::aead::Error)
                .source()
                .is_none()
        );
        assert!(
            CodecError::MissingKey {
                index: 0,
                kind: MissingKeyKind::Gap
            }
            .source()
            .is_none()
        );
        assert!(CodecError::RingEmpty.source().is_none());
        assert!(CodecError::CiphertextTooShort.source().is_none());
        assert!(CodecError::UnknownVersion(2).source().is_none());
        assert!(
            CodecError::UnknownKeyIndex {
                index: 5,
                ring_len: 2
            }
            .source()
            .is_none()
        );
    }

    #[test]
    fn unknown_key_index_display_exact() {
        let err = CodecError::UnknownKeyIndex {
            index: 5,
            ring_len: 2,
        };
        assert_eq!(err.to_string(), "key index 5 not in ring of length 2");
    }

    #[test]
    fn classify_transition_same_codec_is_online_safe() {
        let classification = <Aes256GcmV1 as FieldCodec>::classify_transition::<Aes256GcmV1>();
        assert_eq!(classification, OnlineSafetyClassification::OnlineSafe);
    }

    #[test]
    fn classify_transition_different_codec_is_expand_contract() {
        struct DummyCodec;
        impl FieldCodec for DummyCodec {
            const ID: &'static str = "_djogi_test_dummy_codec";
            type Decoded = String;
            type Encoded = Vec<u8>;
            type Error = CodecError;

            fn encode(
                _model: &'static str,
                _field: &'static str,
                value: &Self::Decoded,
            ) -> Result<Self::Encoded, Self::Error> {
                Ok(value.as_bytes().to_vec())
            }

            fn decode(
                _model: &'static str,
                _field: &'static str,
                stored: &Self::Encoded,
            ) -> Result<Self::Decoded, Self::Error> {
                Ok(String::from_utf8(stored.clone())?)
            }

            fn classify_transition<Other: FieldCodec>() -> OnlineSafetyClassification {
                if Self::ID == Other::ID {
                    OnlineSafetyClassification::OnlineSafe
                } else {
                    OnlineSafetyClassification::ExpandContract
                }
            }
        }

        let classification = <Aes256GcmV1 as FieldCodec>::classify_transition::<DummyCodec>();
        assert_eq!(classification, OnlineSafetyClassification::ExpandContract);
    }

    #[test]
    fn codec_startup_error_display_format() {
        use super::super::CodecStartupError;

        let err = CodecStartupError {
            codec_id: "aes256_gcm_v1",
            env_var: ENV_VAR,
            error: "no field codec key configured".to_owned(),
        };
        let msg = err.to_string();
        assert!(msg.contains("aes256_gcm_v1"));
        assert!(msg.contains(ENV_VAR));
        assert!(msg.contains("no field codec key configured"));
    }
}

/// Ring-isolation tests requiring a specific cold-cache ring state.
///
/// **Single-`OnceLock` honesty:** [`CODEC_RING`] is single-set per process, so
/// at most one "fresh-ring" test in a `cargo test` run actually exercises a cold
/// `load_ring`; the rest hit the populated cache and degrade to best-effort
/// no-ops. Each test here is `#[serial_test::serial]` and guards on the cache
/// state so a populated ring does not make it fail spuriously. The load-bearing
/// guarantees come from (a) `ring_loading_two_keys_active_index_one` +
/// `cross_ring_entry_non_interference`, which set the ring they need first when
/// the cache is cold, and (b) the integration round-trip against a real DB. We
/// do NOT add a `reset_ring()` hook — it would defeat the immutability invariant
/// the whole design rests on.
#[cfg(all(test, feature = "aes-codec"))]
mod ring_isolation_tests {
    use super::*;

    const KEY_0: &str = "0000000000000000000000000000000000000000000000000000000000000001";
    const KEY_1: &str = "0000000000000000000000000000000000000000000000000000000000000002";

    #[serial_test::serial]
    #[test]
    fn ring_loading_one_key_active_index_zero() {
        // With a single-entry ring the active index is 0, so a fresh encode
        // records key_index byte == 0. Only meaningful when this is the first
        // test to populate the cache; otherwise the cached ring decides the
        // active index and we skip the assertion.
        test_with_codec_ring(&[KEY_0], || {
            if CODEC_RING.get().map(|r| r.len()) == Some(1) {
                let blob = Aes256GcmV1::encode("M", "f", &"x".to_string()).unwrap();
                assert_eq!(blob[1], 0, "single-key ring active index must be 0");
            }
        });
    }

    #[serial_test::serial]
    #[test]
    fn ring_loading_two_keys_active_index_one() {
        // With a two-entry ring the active index is 1: a fresh encode records
        // key_index byte == 1. Guarded on the cache holding the 2-entry ring.
        test_with_codec_ring(&[KEY_0, KEY_1], || {
            if CODEC_RING.get().map(|r| r.len()) == Some(2) {
                let blob = Aes256GcmV1::encode("M", "f", &"x".to_string()).unwrap();
                assert_eq!(blob[1], 1, "two-key ring active index must be 1");
            }
        });
    }

    #[serial_test::serial]
    #[test]
    fn cross_ring_entry_non_interference() {
        // key_A@0, key_B@1, same (model, field, plaintext): forging a key_A blob
        // to claim key_index 1 (key_B's slot) and decoding fails authentication.
        // This guards the regression that would drop key_index from HKDF
        // derivation. Guarded on the 2-entry ring being active.
        test_with_codec_ring(&[KEY_0, KEY_1], || {
            if CODEC_RING.get().map(|r| r.len()) == Some(2) {
                // Active index is 1, so a fresh blob records key_index == 1 and
                // derives its subkey from ring[1]. Forge it to claim key_index 0
                // (ring[0] = key_A) — the subkey then mismatches and auth fails.
                let mut blob = Aes256GcmV1::encode("M", "f", &"x".to_string()).unwrap();
                assert_eq!(blob[1], 1);
                blob[1] = 0; // claim key_A's slot for a key_B-encrypted blob
                let err = Aes256GcmV1::decode("M", "f", &blob).unwrap_err();
                assert!(
                    matches!(err, CodecError::AeadError(_)),
                    "forging the key_index must fail authentication: {err:?}"
                );
            }
        });
    }

    #[serial_test::serial]
    #[test]
    fn key_rotation_round_trip() {
        // Rotation honesty: because the OnceLock is single-set, a second
        // `test_with_codec_ring` call does NOT re-populate the ring. This test
        // can only exercise rotation if it is the first to touch the cache. We
        // assert what the live cache state supports and document that the
        // load-bearing rotation coverage is the integration round-trip + the
        // two-key load test, not this best-effort assertion.
        test_with_codec_ring(&[KEY_0], || {
            let len = CODEC_RING.get().map(|r| r.len());
            if len == Some(1) {
                let blob = Aes256GcmV1::encode("M", "f", &"v".to_string()).unwrap();
                assert_eq!(blob[1], 0, "first ring entry encodes under index 0");
                // Old blob decodes under index 0 regardless of later cache state.
                assert_eq!(Aes256GcmV1::decode("M", "f", &blob).unwrap(), "v");
            }
        });
    }

    #[serial_test::serial]
    #[test]
    fn ring_immutability() {
        // Set the ring, then mutate the env var to garbage; a subsequent
        // encode/decode still succeeds (the cached ring wins).
        test_with_codec_ring(&[KEY_0], || {
            // Only meaningful once the ring is cached.
            if CODEC_RING.get().is_some() {
                unsafe { std::env::set_var("DJOGI_FIELD_CODEC_KEY_0", "not-hex-garbage") };
                let blob = Aes256GcmV1::encode("M", "f", &"v".to_string()).unwrap();
                assert_eq!(Aes256GcmV1::decode("M", "f", &blob).unwrap(), "v");
            }
        });
    }

    #[serial_test::serial]
    #[test]
    fn missing_ring_startup_failure() {
        // With no DJOGI_FIELD_CODEC_KEY_* set AND CODEC_RING unset, load_ring →
        // RingEmpty. Best-effort: only meaningful with a cold OnceLock.
        let _lock = TEST_CODEC_ENV_MUTEX.lock().unwrap();
        let prev: Vec<Option<String>> = (0..32)
            .map(|i| std::env::var(format!("DJOGI_FIELD_CODEC_KEY_{i}")).ok())
            .collect();
        for i in 0..32 {
            unsafe { std::env::remove_var(format!("DJOGI_FIELD_CODEC_KEY_{i}")) };
        }
        if CODEC_RING.get().is_none() {
            let err = load_ring().unwrap_err();
            assert!(matches!(err, CodecError::RingEmpty), "got: {err:?}");
        }
        for (i, p) in prev.into_iter().enumerate() {
            let name = format!("DJOGI_FIELD_CODEC_KEY_{i}");
            match p {
                Some(v) => unsafe { std::env::set_var(&name, &v) },
                None => unsafe { std::env::remove_var(&name) },
            }
        }
    }

    #[serial_test::serial]
    #[test]
    fn ring_gap_detection() {
        // _0 and _2 set, _1 unset → MissingKey { index: 1, kind: Gap }. The gap
        // is detected when scanning index 2 (the first present entry past the
        // hole), so the missing index reported is 1 (= ring.len() at that point).
        // Best-effort: only meaningful with a cold OnceLock.
        let _lock = TEST_CODEC_ENV_MUTEX.lock().unwrap();
        let prev: Vec<Option<String>> = (0..32)
            .map(|i| std::env::var(format!("DJOGI_FIELD_CODEC_KEY_{i}")).ok())
            .collect();
        for i in 0..32 {
            unsafe { std::env::remove_var(format!("DJOGI_FIELD_CODEC_KEY_{i}")) };
        }
        unsafe {
            std::env::set_var("DJOGI_FIELD_CODEC_KEY_0", KEY_0);
            std::env::set_var("DJOGI_FIELD_CODEC_KEY_2", KEY_1);
        }
        if CODEC_RING.get().is_none() {
            let err = load_ring().unwrap_err();
            match err {
                CodecError::MissingKey {
                    index,
                    kind: MissingKeyKind::Gap,
                } => {
                    assert_eq!(index, 1);
                    assert!(err.to_string().contains("DJOGI_FIELD_CODEC_KEY_1"));
                }
                other => panic!("expected MissingKey gap at index 1, got: {other:?}"),
            }
        }
        for (i, p) in prev.into_iter().enumerate() {
            let name = format!("DJOGI_FIELD_CODEC_KEY_{i}");
            match p {
                Some(v) => unsafe { std::env::set_var(&name, &v) },
                None => unsafe { std::env::remove_var(&name) },
            }
        }
    }

    #[serial_test::serial]
    #[test]
    fn malformed_entry_detection() {
        // _0 set to a non-hex value → MissingKey { index: 0, Malformed }.
        // Best-effort: only meaningful with a cold OnceLock.
        let _lock = TEST_CODEC_ENV_MUTEX.lock().unwrap();
        let prev: Vec<Option<String>> = (0..32)
            .map(|i| std::env::var(format!("DJOGI_FIELD_CODEC_KEY_{i}")).ok())
            .collect();
        for i in 0..32 {
            unsafe { std::env::remove_var(format!("DJOGI_FIELD_CODEC_KEY_{i}")) };
        }
        unsafe { std::env::set_var("DJOGI_FIELD_CODEC_KEY_0", "not-64-lowercase-hex") };
        if CODEC_RING.get().is_none() {
            let err = load_ring().unwrap_err();
            match err {
                CodecError::MissingKey {
                    index,
                    kind: MissingKeyKind::Malformed,
                } => {
                    assert_eq!(index, 0);
                    assert!(err.to_string().contains("DJOGI_FIELD_CODEC_KEY_0"));
                }
                other => panic!("expected MissingKey malformed at index 0, got: {other:?}"),
            }
        }
        for (i, p) in prev.into_iter().enumerate() {
            let name = format!("DJOGI_FIELD_CODEC_KEY_{i}");
            match p {
                Some(v) => unsafe { std::env::set_var(&name, &v) },
                None => unsafe { std::env::remove_var(&name) },
            }
        }
    }

    #[serial_test::serial]
    #[test]
    fn base_key_only_missing_distinction() {
        // No vars → RingEmpty; _0 unset but _1 present → MissingKey gap at 0.
        // Best-effort: only meaningful with a cold OnceLock.
        let _lock = TEST_CODEC_ENV_MUTEX.lock().unwrap();
        let prev: Vec<Option<String>> = (0..32)
            .map(|i| std::env::var(format!("DJOGI_FIELD_CODEC_KEY_{i}")).ok())
            .collect();
        for i in 0..32 {
            unsafe { std::env::remove_var(format!("DJOGI_FIELD_CODEC_KEY_{i}")) };
        }
        unsafe { std::env::set_var("DJOGI_FIELD_CODEC_KEY_1", KEY_1) };
        if CODEC_RING.get().is_none() {
            let err = load_ring().unwrap_err();
            match err {
                CodecError::MissingKey {
                    index: 0,
                    kind: MissingKeyKind::Gap,
                } => {
                    assert!(err.to_string().contains("DJOGI_FIELD_CODEC_KEY_0"));
                }
                other => panic!("expected MissingKey gap at index 0, got: {other:?}"),
            }
        }
        for (i, p) in prev.into_iter().enumerate() {
            let name = format!("DJOGI_FIELD_CODEC_KEY_{i}");
            match p {
                Some(v) => unsafe { std::env::set_var(&name, &v) },
                None => unsafe { std::env::remove_var(&name) },
            }
        }
    }
}