Skip to main content

matter_controller/
snapshot.rs

1//! Versioned TLV serialization of [`ControllerState`].
2//!
3//! Layout (all members context-tagged):
4//! ```text
5//! root: Structure { C0: version(u8), C1: Array[fabric] }
6//! fabric: Structure {
7//!   C0: fabric_id(uint), C1: ipk(bytes16), C2: rcac_cert(bytes,TLV),
8//!   C3: rcac_pkcs8(bytes), C4: commissioner(struct), C5: Array[device],
9//!   C6: Array[group_key_set] (optional — absent in v1 snapshots written
10//!       before group-key support; defaults to empty on read),
11//!   C7: outbound_group_counter(uint, optional — same default rule),
12//!   C8: Array[icd_registration] (optional — same default rule),
13//!   C9: icac_cert(bytes,TLV, optional — present only when the fabric has
14//!       an ICAC), C10: icac_pkcs8(bytes, optional — present iff C9 is)
15//! }
16//! commissioner: Structure { C0: node_id(uint), C1: op_pkcs8(bytes), C2: noc(bytes,TLV) }
17//! device: Structure {
18//!   C0: node_id(uint), C1: peer_noc_public_key(bytes65),
19//!   C2: resumption_record(bytes, optional), C3: last_known_addr(utf8, optional),
20//!   C4: vendor_id(uint, optional), C5: product_id(uint, optional),
21//!   C6: label(utf8, optional) — all three additive/ICAC-style, absent means
22//!       `None`
23//! }
24//! group_key_set: Structure {
25//!   C0: key_set_id(uint), C1: epoch_key(bytes16), C2: epoch_start_time(uint)
26//! }
27//! ```
28//!
29//! ## Backward compatibility
30//!
31//! Fabric-struct C6, C7, C8, C9, and C10 are optional tags: a v1 snapshot
32//! without them (written by an older binary) still deserializes cleanly —
33//! `group_keys` defaults to `Vec::new()`, `outbound_group_counter` to `0`,
34//! `icd_clients` to `Vec::new()`, and `icac` to `None`.  C9/C10 are
35//! themselves only written when a fabric has an ICAC, so a fabric without
36//! one round-trips to byte-identical output regardless of ICAC support
37//! existing in the binary.
38//!
39//! Device-struct C4, C5, and C6 (`vendor_id`/`product_id`/`label`) follow the same
40//! rule: they're only written when the corresponding field is `Some`, and a
41//! device struct without them deserializes to `vendor_id`/`product_id`/
42//! `label` all `None`.
43//!
44//! No version bump is needed for any of the above because the tag-keyed
45//! deserializer simply treats absent tags as defaults.
46
47use matter_cert::MatterCertificate;
48use matter_codec::{Tag, TlvWriter, Value};
49
50use crate::error::Error;
51use crate::state::{
52    CommissionerIdentity, ControllerState, DeviceEntry, FabricEntry, GroupKeySetConfig,
53    IcacIdentity,
54};
55
56/// Current snapshot schema version.
57pub(crate) const SNAPSHOT_VERSION: u8 = 1;
58
59/// Serialize controller state into an opaque TLV blob.
60///
61/// # Errors
62///
63/// Returns [`Error::Cert`] if a certificate fails to serialize, or
64/// [`Error::Codec`] if TLV encoding fails.
65pub(crate) fn serialize(state: &ControllerState) -> Result<Vec<u8>, Error> {
66    let mut fabrics = Vec::with_capacity(state.fabrics.len());
67    for f in &state.fabrics {
68        fabrics.push(fabric_to_value(f)?);
69    }
70    let root = Value::Structure(vec![
71        (Tag::Context(0), Value::Uint(u64::from(SNAPSHOT_VERSION))),
72        (Tag::Context(1), Value::Array(fabrics)),
73    ]);
74
75    let mut out = Vec::new();
76    let mut w = TlvWriter::new(&mut out);
77    w.write_value(Tag::Anonymous, &root)?;
78    Ok(out)
79}
80
81fn fabric_to_value(f: &FabricEntry) -> Result<Value, Error> {
82    let devices = f.devices.iter().map(device_to_value).collect();
83    let group_keys: Vec<Value> = f.group_keys.iter().map(group_key_to_value).collect();
84    let icd_clients: Vec<Value> = f
85        .icd_clients
86        .iter()
87        .map(icd_registration_to_value)
88        .collect();
89    let mut members = vec![
90        (Tag::Context(0), Value::Uint(f.fabric_id)),
91        (Tag::Context(1), Value::Bytes(f.ipk.to_vec())),
92        (Tag::Context(2), Value::Bytes(f.rcac_cert.to_tlv()?)),
93        (Tag::Context(3), Value::Bytes(f.rcac_pkcs8.clone())),
94        (Tag::Context(4), commissioner_to_value(&f.commissioner)?),
95        (Tag::Context(5), Value::Array(devices)),
96        (Tag::Context(6), Value::Array(group_keys)),
97        (
98            Tag::Context(7),
99            Value::Uint(u64::from(f.outbound_group_counter)),
100        ),
101        (Tag::Context(8), Value::Array(icd_clients)),
102    ];
103    // C9/C10 (ICAC) are omitted entirely when the fabric has no ICAC, so a
104    // fabric without one serializes to byte-identical output regardless of
105    // ICAC support existing in the binary (backward compatibility).
106    if let Some(icac) = &f.icac {
107        members.push((Tag::Context(9), Value::Bytes(icac.cert.to_tlv()?)));
108        members.push((Tag::Context(10), Value::Bytes(icac.pkcs8.clone())));
109    }
110    Ok(Value::Structure(members))
111}
112
113fn icd_registration_to_value(r: &crate::icd::IcdRegistration) -> Value {
114    Value::Structure(vec![
115        (Tag::Context(0), Value::Uint(r.node_id)),
116        (Tag::Context(1), Value::Uint(r.check_in_node_id)),
117        (Tag::Context(2), Value::Uint(r.monitored_subject)),
118        (Tag::Context(3), Value::Bytes(r.key.to_vec())),
119        (Tag::Context(4), Value::Uint(u64::from(r.start_counter))),
120    ])
121}
122
123fn group_key_to_value(k: &GroupKeySetConfig) -> Value {
124    Value::Structure(vec![
125        (Tag::Context(0), Value::Uint(u64::from(k.key_set_id))),
126        (Tag::Context(1), Value::Bytes(k.epoch_key.to_vec())),
127        (Tag::Context(2), Value::Uint(k.epoch_start_time)),
128    ])
129}
130
131fn commissioner_to_value(c: &CommissionerIdentity) -> Result<Value, Error> {
132    Ok(Value::Structure(vec![
133        (Tag::Context(0), Value::Uint(c.node_id)),
134        (Tag::Context(1), Value::Bytes(c.operational_pkcs8.clone())),
135        (Tag::Context(2), Value::Bytes(c.noc.to_tlv()?)),
136    ]))
137}
138
139fn device_to_value(d: &DeviceEntry) -> Value {
140    let mut members = vec![
141        (Tag::Context(0), Value::Uint(d.node_id)),
142        (
143            Tag::Context(1),
144            Value::Bytes(d.peer_noc_public_key.to_vec()),
145        ),
146    ];
147    if let Some(rr) = &d.resumption_record {
148        members.push((Tag::Context(2), Value::Bytes(rr.clone())));
149    }
150    if let Some(addr) = &d.last_known_addr {
151        members.push((Tag::Context(3), Value::Utf8(addr.clone())));
152    }
153    if let Some(vid) = d.vendor_id {
154        members.push((Tag::Context(4), Value::Uint(u64::from(vid))));
155    }
156    if let Some(pid) = d.product_id {
157        members.push((Tag::Context(5), Value::Uint(u64::from(pid))));
158    }
159    if let Some(label) = &d.label {
160        members.push((Tag::Context(6), Value::Utf8(label.clone())));
161    }
162    Value::Structure(members)
163}
164
165/// Deserialize a snapshot blob into [`ControllerState`].
166///
167/// # Errors
168///
169/// Returns [`Error::Snapshot`] if the structure or version is invalid,
170/// [`Error::Codec`] on TLV decode failure, or [`Error::Cert`] if an
171/// embedded certificate fails to parse.
172pub(crate) fn deserialize(bytes: &[u8]) -> Result<ControllerState, Error> {
173    use matter_codec::TlvReader;
174
175    let mut r = TlvReader::new(bytes);
176    let (_tag, value) = r.read_value()?;
177    let root = as_struct(&value)?;
178
179    let version = get_uint(root, 0)?;
180    if version != u64::from(SNAPSHOT_VERSION) {
181        return Err(Error::Snapshot(format!(
182            "unsupported snapshot version {version}"
183        )));
184    }
185
186    let fabrics_val =
187        get(root, 1).ok_or_else(|| Error::Snapshot("missing fabrics array".into()))?;
188    let mut fabrics = Vec::new();
189    for fv in as_array(fabrics_val)? {
190        fabrics.push(fabric_from_value(fv)?);
191    }
192    Ok(ControllerState { fabrics })
193}
194
195fn fabric_from_value(v: &Value) -> Result<FabricEntry, Error> {
196    let m = as_struct(v)?;
197    let commissioner_val =
198        get(m, 4).ok_or_else(|| Error::Snapshot("missing commissioner".into()))?;
199    let devices_val = get(m, 5).ok_or_else(|| Error::Snapshot("missing devices array".into()))?;
200    let mut devices = Vec::new();
201    for dv in as_array(devices_val)? {
202        devices.push(device_from_value(dv)?);
203    }
204
205    // t6 / t7 are optional (absent in v1 snapshots written before group-key
206    // support was added).  Default to empty / 0 when missing — this keeps old
207    // stores loadable without a version bump.
208    let group_keys = match get(m, 6) {
209        Some(arr) => {
210            let mut keys = Vec::new();
211            for kv in as_array(arr)? {
212                keys.push(group_key_from_value(kv)?);
213            }
214            keys
215        }
216        None => Vec::new(),
217    };
218    let outbound_group_counter = match get(m, 7) {
219        Some(Value::Uint(n)) => u32::try_from(*n)
220            .map_err(|_| Error::Snapshot("outbound_group_counter exceeds u32 range".into()))?,
221        _ => 0,
222    };
223    // t8 (ICD registrations) is optional — absent in snapshots written before
224    // ICD support. Default to empty (no version bump).
225    let icd_clients = match get(m, 8) {
226        Some(arr) => {
227            let mut regs = Vec::new();
228            for rv in as_array(arr)? {
229                regs.push(icd_registration_from_value(rv)?);
230            }
231            regs
232        }
233        None => Vec::new(),
234    };
235
236    // C9/C10 (ICAC) are optional and only present together — absent in
237    // snapshots written before ICAC support, or for a fabric that never
238    // adopted one. Only C9-present-and-C10-present decodes to `Some`;
239    // anything else (both absent, or one without the other) decodes to
240    // `None` rather than erroring, matching the other optional tags' rule
241    // of "absent means default".
242    let icac = match (get(m, 9), get(m, 10)) {
243        (Some(Value::Bytes(cert_tlv)), Some(Value::Bytes(pkcs8))) => Some(IcacIdentity {
244            cert: MatterCertificate::from_tlv(cert_tlv)?,
245            pkcs8: pkcs8.clone(),
246        }),
247        _ => None,
248    };
249
250    Ok(FabricEntry {
251        fabric_id: get_uint(m, 0)?,
252        ipk: byte_array::<16>(get_bytes(m, 1)?, "ipk")?,
253        rcac_cert: MatterCertificate::from_tlv(get_bytes(m, 2)?)?,
254        rcac_pkcs8: get_bytes(m, 3)?.to_vec(),
255        commissioner: commissioner_from_value(commissioner_val)?,
256        devices,
257        group_keys,
258        outbound_group_counter,
259        icd_clients,
260        icac,
261    })
262}
263
264fn icd_registration_from_value(v: &Value) -> Result<crate::icd::IcdRegistration, Error> {
265    let m = as_struct(v)?;
266    let start_counter = u32::try_from(get_uint(m, 4)?)
267        .map_err(|_| Error::Snapshot("icd start_counter exceeds u32 range".into()))?;
268    Ok(crate::icd::IcdRegistration::new(
269        get_uint(m, 0)?,
270        get_uint(m, 1)?,
271        get_uint(m, 2)?,
272        byte_array::<16>(get_bytes(m, 3)?, "icd key")?,
273        start_counter,
274    ))
275}
276
277fn group_key_from_value(v: &Value) -> Result<GroupKeySetConfig, Error> {
278    let m = as_struct(v)?;
279    let key_set_id = u16::try_from(get_uint(m, 0)?)
280        .map_err(|_| Error::Snapshot("key_set_id exceeds u16 range".into()))?;
281    let epoch_key = byte_array::<16>(get_bytes(m, 1)?, "epoch_key")?;
282    let epoch_start_time = get_uint(m, 2)?;
283    Ok(GroupKeySetConfig::new(
284        key_set_id,
285        epoch_key,
286        epoch_start_time,
287    ))
288}
289
290fn commissioner_from_value(v: &Value) -> Result<CommissionerIdentity, Error> {
291    let m = as_struct(v)?;
292    Ok(CommissionerIdentity {
293        node_id: get_uint(m, 0)?,
294        operational_pkcs8: get_bytes(m, 1)?.to_vec(),
295        noc: MatterCertificate::from_tlv(get_bytes(m, 2)?)?,
296    })
297}
298
299fn device_from_value(v: &Value) -> Result<DeviceEntry, Error> {
300    let m = as_struct(v)?;
301    let resumption_record = match get(m, 2) {
302        Some(Value::Bytes(b)) => Some(b.clone()),
303        _ => None,
304    };
305    let last_known_addr = match get(m, 3) {
306        Some(Value::Utf8(s)) => Some(s.clone()),
307        _ => None,
308    };
309    // C4/C5/C6 are optional tags (absent in snapshots written before device
310    // metadata support): a v1 device struct with only t0..t3 still
311    // deserializes cleanly, defaulting vendor_id/product_id/label to `None`
312    // — same additive-optional-tag discipline as C6/C7 (group keys) and
313    // C9/C10 (ICAC) above.
314    let vendor_id = match get(m, 4) {
315        Some(Value::Uint(n)) => u16::try_from(*n).ok(),
316        _ => None,
317    };
318    let product_id = match get(m, 5) {
319        Some(Value::Uint(n)) => u16::try_from(*n).ok(),
320        _ => None,
321    };
322    let label = match get(m, 6) {
323        Some(Value::Utf8(s)) => Some(s.clone()),
324        _ => None,
325    };
326    Ok(DeviceEntry {
327        node_id: get_uint(m, 0)?,
328        peer_noc_public_key: byte_array::<65>(get_bytes(m, 1)?, "peer_noc_public_key")?,
329        resumption_record,
330        last_known_addr,
331        vendor_id,
332        product_id,
333        label,
334    })
335}
336
337// --- small TLV-Value accessors ---
338
339fn as_struct(v: &Value) -> Result<&[(Tag, Value)], Error> {
340    match v {
341        Value::Structure(members) => Ok(members),
342        _ => Err(Error::Snapshot("expected structure".into())),
343    }
344}
345
346fn as_array(v: &Value) -> Result<&[Value], Error> {
347    match v {
348        Value::Array(items) => Ok(items),
349        _ => Err(Error::Snapshot("expected array".into())),
350    }
351}
352
353fn get(members: &[(Tag, Value)], ctx: u8) -> Option<&Value> {
354    members
355        .iter()
356        .find(|(t, _)| *t == Tag::Context(ctx))
357        .map(|(_, v)| v)
358}
359
360fn get_uint(members: &[(Tag, Value)], ctx: u8) -> Result<u64, Error> {
361    match get(members, ctx) {
362        Some(Value::Uint(n)) => Ok(*n),
363        _ => Err(Error::Snapshot(format!(
364            "missing or non-uint at context {ctx}"
365        ))),
366    }
367}
368
369fn get_bytes(members: &[(Tag, Value)], ctx: u8) -> Result<&[u8], Error> {
370    match get(members, ctx) {
371        Some(Value::Bytes(b)) => Ok(b),
372        _ => Err(Error::Snapshot(format!(
373            "missing or non-bytes at context {ctx}"
374        ))),
375    }
376}
377
378fn byte_array<const N: usize>(b: &[u8], field: &str) -> Result<[u8; N], Error> {
379    b.try_into()
380        .map_err(|_| Error::Snapshot(format!("{field}: expected {N} bytes, got {}", b.len())))
381}
382
383#[cfg(test)]
384#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md allows unwrap/expect with justification.
385mod tests {
386    use super::*;
387    use crate::fabric::{create_fabric, FabricConfig};
388    use crate::store::{ControllerStore, FileStore};
389    use matter_cert::MatterTime;
390    use matter_commissioning::SystemNocRng;
391    use matter_crypto::Signer as _;
392
393    /// A unique temp path per (process, call) — a fixed shared path races when
394    /// two test processes (or an overlapping re-run) touch the same file.
395    fn temp_path(name: &str) -> std::path::PathBuf {
396        use std::sync::atomic::{AtomicU32, Ordering};
397        static COUNTER: AtomicU32 = AtomicU32::new(0);
398        let uniq = COUNTER.fetch_add(1, Ordering::Relaxed);
399        let mut p = std::env::temp_dir();
400        p.push(format!(
401            "matter-controller-restart-{name}-{}-{uniq}",
402            std::process::id()
403        ));
404        let _ = std::fs::remove_file(&p);
405        let _ = std::fs::remove_file(p.with_extension("tmp"));
406        p
407    }
408
409    /// Acceptance test (white-box: exercises the crate-internal `create_fabric`,
410    /// `serialize`/`deserialize`, and signer reconstruction): a fabric minted,
411    /// persisted via `FileStore`, and reloaded yields a byte-identical
412    /// commissioner identity whose key still signs.
413    #[test]
414    fn commissioner_identity_is_stable_across_restart() {
415        let cfg = FabricConfig::new(
416            0x0102_0304_0506_0708,
417            1,
418            0x0000_0000_0000_0001,
419            (
420                MatterTime::from_unix_secs(1_700_000_000),
421                MatterTime::NO_EXPIRY,
422            ),
423        );
424        let fabric = create_fabric(&cfg, &SystemNocRng).expect("create_fabric");
425        let original_state = ControllerState::new(vec![fabric]);
426
427        // "First boot": serialize and persist.
428        let path = temp_path("identity");
429        let store = FileStore::new(&path);
430        store
431            .save(&serialize(&original_state).expect("serialize"))
432            .expect("save");
433
434        // "Second boot": load and deserialize from disk.
435        let loaded = store.load().expect("load").expect("snapshot present");
436        let restored = deserialize(&loaded).expect("deserialize");
437
438        let before = &original_state.fabrics[0];
439        let after = &restored.fabrics[0];
440
441        assert_eq!(after.commissioner.node_id, before.commissioner.node_id);
442        assert_eq!(
443            after.commissioner.noc.to_tlv().unwrap(),
444            before.commissioner.noc.to_tlv().unwrap(),
445            "commissioner NOC must survive restart byte-for-byte"
446        );
447
448        // The reloaded operational key still signs and matches the NOC.
449        let signer = after
450            .commissioner_signer()
451            .expect("reload commissioner signer");
452        assert_eq!(
453            signer.public_key().as_bytes(),
454            after.commissioner.noc.public_key().as_bytes()
455        );
456        let sig_bytes = signer.sign_p256_sha256(b"post-restart").expect("sign");
457        let sig = matter_cert::Signature::new(sig_bytes);
458        signer
459            .public_key()
460            .verify(b"post-restart", &sig)
461            .expect("post-restart signature verifies");
462
463        // The reconstructed FabricRecord is usable (RCAC signer reloads).
464        let record = after.to_fabric_record().expect("to_fabric_record");
465        assert_eq!(record.fabric_id, cfg.fabric_id);
466
467        let _ = std::fs::remove_file(&path);
468    }
469
470    fn sample_state() -> ControllerState {
471        let cfg = FabricConfig {
472            fabric_id: 0x1122_3344_5566_7788,
473            rcac_id: 1,
474            commissioner_node_id: 0x0000_0000_0000_0001,
475            validity: (
476                MatterTime::from_unix_secs(1_700_000_000),
477                MatterTime::NO_EXPIRY,
478            ),
479            issue_icac: false,
480        };
481        let mut fabric = create_fabric(&cfg, &SystemNocRng).expect("create_fabric");
482        fabric.devices.push(DeviceEntry {
483            node_id: 0xABCD,
484            peer_noc_public_key: [0x04; 65],
485            resumption_record: Some(vec![1, 2, 3, 4]),
486            last_known_addr: Some("[fe80::1]:5540".to_string()),
487            vendor_id: None,
488            product_id: None,
489            label: None,
490        });
491        fabric.devices.push(DeviceEntry {
492            node_id: 0xBEEF,
493            peer_noc_public_key: [0x04; 65],
494            resumption_record: None,
495            last_known_addr: None,
496            vendor_id: None,
497            product_id: None,
498            label: None,
499        });
500        ControllerState {
501            fabrics: vec![fabric],
502        }
503    }
504
505    #[test]
506    fn round_trips_a_full_state() {
507        let state = sample_state();
508        let bytes = serialize(&state).expect("serialize");
509        let back = deserialize(&bytes).expect("deserialize");
510
511        assert_eq!(back.fabrics.len(), 1);
512        let (a, b) = (&state.fabrics[0], &back.fabrics[0]);
513        assert_eq!(a.fabric_id, b.fabric_id);
514        assert_eq!(a.ipk, b.ipk);
515        assert_eq!(a.rcac_pkcs8, b.rcac_pkcs8);
516        assert_eq!(a.rcac_cert.to_tlv().unwrap(), b.rcac_cert.to_tlv().unwrap());
517        assert_eq!(a.commissioner.node_id, b.commissioner.node_id);
518        assert_eq!(
519            a.commissioner.operational_pkcs8,
520            b.commissioner.operational_pkcs8
521        );
522        assert_eq!(
523            a.commissioner.noc.to_tlv().unwrap(),
524            b.commissioner.noc.to_tlv().unwrap()
525        );
526        assert_eq!(a.devices.len(), b.devices.len());
527        assert_eq!(a.devices[0].node_id, b.devices[0].node_id);
528        assert_eq!(
529            a.devices[0].resumption_record,
530            b.devices[0].resumption_record
531        );
532        assert_eq!(a.devices[0].last_known_addr, b.devices[0].last_known_addr);
533        assert_eq!(a.devices[1].resumption_record, None);
534        assert_eq!(a.devices[1].last_known_addr, None);
535    }
536
537    #[test]
538    fn empty_state_round_trips() {
539        let bytes = serialize(&ControllerState::default()).expect("serialize");
540        assert!(deserialize(&bytes).expect("deserialize").fabrics.is_empty());
541    }
542
543    #[test]
544    fn rejects_unknown_version() {
545        // Hand-build a root with version 99.
546        let root = Value::Structure(vec![
547            (Tag::Context(0), Value::Uint(99)),
548            (Tag::Context(1), Value::Array(vec![])),
549        ]);
550        let mut out = Vec::new();
551        let mut w = TlvWriter::new(&mut out);
552        w.write_value(Tag::Anonymous, &root).unwrap();
553        let err = deserialize(&out).expect_err("must reject");
554        assert!(matches!(err, Error::Snapshot(_)));
555    }
556
557    // --- property-based round-trip (CLAUDE.md: encoders get a proptest) ---
558
559    use proptest::prelude::*;
560    use std::sync::OnceLock;
561
562    /// Mint one real fabric and reuse it across all proptest cases — key
563    /// generation is expensive, but cloning a `FabricEntry` is cheap.
564    fn shared_fabric() -> &'static FabricEntry {
565        static FABRIC: OnceLock<FabricEntry> = OnceLock::new();
566        FABRIC.get_or_init(|| {
567            let cfg = FabricConfig {
568                fabric_id: 0x0102_0304_0506_0708,
569                rcac_id: 1,
570                commissioner_node_id: 0x0000_0000_0000_0001,
571                validity: (
572                    MatterTime::from_unix_secs(1_700_000_000),
573                    MatterTime::NO_EXPIRY,
574                ),
575                issue_icac: false,
576            };
577            create_fabric(&cfg, &SystemNocRng).expect("mint shared fabric")
578        })
579    }
580
581    prop_compose! {
582        fn arb_device()(
583            node_id in any::<u64>(),
584            pk in prop::collection::vec(any::<u8>(), 65),
585            rr in prop::option::of(prop::collection::vec(any::<u8>(), 0..40)),
586            addr in prop::option::of("[ -~]{0,32}"),
587        ) -> DeviceEntry {
588            let mut peer_noc_public_key = [0u8; 65];
589            peer_noc_public_key.copy_from_slice(&pk);
590            DeviceEntry {
591                node_id,
592                peer_noc_public_key,
593                resumption_record: rr,
594                last_known_addr: addr,
595                vendor_id: None,
596                product_id: None,
597                label: None,
598            }
599        }
600    }
601
602    proptest! {
603        /// `deserialize(serialize(state)) == state` for arbitrary device lists.
604        #[test]
605        fn snapshot_round_trips(devices in prop::collection::vec(arb_device(), 0..6)) {
606            let mut fabric = shared_fabric().clone();
607            fabric.devices = devices.clone();
608            let state = ControllerState { fabrics: vec![fabric] };
609
610            let bytes = serialize(&state).expect("serialize");
611            let back = deserialize(&bytes).expect("deserialize");
612
613            prop_assert_eq!(back.fabrics.len(), 1);
614            let dev_back = &back.fabrics[0].devices;
615            prop_assert_eq!(dev_back.len(), devices.len());
616            for (a, b) in devices.iter().zip(dev_back.iter()) {
617                prop_assert_eq!(a.node_id, b.node_id);
618                prop_assert_eq!(a.peer_noc_public_key, b.peer_noc_public_key);
619                prop_assert_eq!(&a.resumption_record, &b.resumption_record);
620                prop_assert_eq!(&a.last_known_addr, &b.last_known_addr);
621            }
622        }
623    }
624
625    // --- group-key persistence tests ---
626
627    #[test]
628    fn group_keys_round_trip() {
629        // A FabricEntry WITH group_keys and a non-zero counter must survive
630        // serialize → deserialize with all group fields preserved.
631        let mut fabric = shared_fabric().clone();
632        fabric.group_keys = vec![
633            GroupKeySetConfig::new(0x0001, [0xAA; 16], 1_700_000_000),
634            GroupKeySetConfig::new(0x0002, [0xBB; 16], 1_700_100_000),
635        ];
636        fabric.outbound_group_counter = 42;
637        let state = ControllerState {
638            fabrics: vec![fabric.clone()],
639        };
640
641        let bytes = serialize(&state).expect("serialize");
642        let back = deserialize(&bytes).expect("deserialize");
643
644        assert_eq!(back.fabrics.len(), 1);
645        let f = &back.fabrics[0];
646        assert_eq!(f.outbound_group_counter, 42);
647        assert_eq!(f.group_keys.len(), 2);
648        assert_eq!(f.group_keys[0].key_set_id, 0x0001);
649        assert_eq!(f.group_keys[0].epoch_key, [0xAA; 16]);
650        assert_eq!(f.group_keys[0].epoch_start_time, 1_700_000_000);
651        assert_eq!(f.group_keys[1].key_set_id, 0x0002);
652        assert_eq!(f.group_keys[1].epoch_key, [0xBB; 16]);
653        assert_eq!(f.group_keys[1].epoch_start_time, 1_700_100_000);
654    }
655
656    #[test]
657    fn icd_clients_round_trip() {
658        // A FabricEntry WITH ICD registrations must survive serialize →
659        // deserialize with all fields preserved (additive t8, no version bump).
660        let mut fabric = shared_fabric().clone();
661        fabric.icd_clients = vec![
662            crate::icd::IcdRegistration::new(0x0042, 1, 1, [0xCC; 16], 7),
663            crate::icd::IcdRegistration::new(0x0043, 1, 2, [0xDD; 16], 99),
664        ];
665        let state = ControllerState {
666            fabrics: vec![fabric.clone()],
667        };
668        let bytes = serialize(&state).expect("serialize");
669        let back = deserialize(&bytes).expect("deserialize");
670        assert_eq!(back.fabrics[0].icd_clients, fabric.icd_clients);
671    }
672
673    // --- ICAC persistence (C9/C10) ---
674
675    /// Build a throwaway ICAC cert (signed by `fabric`'s RCAC key) + a fresh
676    /// PKCS#8 signing key for it, wrapped as an [`IcacIdentity`].
677    fn sample_icac(fabric: &FabricEntry) -> crate::state::IcacIdentity {
678        use matter_cert::operational::{icac, sign_with_ring, IcacParams};
679        use matter_cert::PublicKey;
680        use matter_crypto::{RingSigner, Signer};
681
682        let (icac_signer, icac_pkcs8) = RingSigner::generate().expect("generate icac key");
683        let icac_public_key =
684            PublicKey::new(*icac_signer.public_key().as_bytes()).expect("valid P-256 public key");
685        let issuer_skid = fabric
686            .rcac_cert
687            .extensions()
688            .subject_key_identifier
689            .expect("rcac has SKID");
690
691        let unsigned = icac(IcacParams::new(
692            0x0000_0000_0000_0099,
693            fabric.rcac_cert.subject().clone(),
694            issuer_skid,
695            icac_public_key,
696            vec![0x01],
697            MatterTime::from_unix_secs(1_700_000_000),
698            MatterTime::NO_EXPIRY,
699        ))
700        .expect("build unsigned icac");
701        let cert = sign_with_ring(unsigned, &fabric.rcac_pkcs8).expect("sign icac");
702
703        crate::state::IcacIdentity {
704            cert,
705            pkcs8: icac_pkcs8,
706        }
707    }
708
709    #[test]
710    fn snapshot_round_trips_fabric_with_icac() {
711        // A FabricEntry WITH an ICAC must survive serialize -> deserialize
712        // with both the cert and the signing key preserved (C9/C10).
713        let mut fabric = shared_fabric().clone();
714        let icac_identity = sample_icac(&fabric);
715        fabric.icac = Some(icac_identity);
716
717        let state = ControllerState {
718            fabrics: vec![fabric.clone()],
719        };
720        let bytes = serialize(&state).expect("serialize");
721        let back = deserialize(&bytes).expect("deserialize");
722
723        let want = fabric.icac.as_ref().expect("icac set on input fabric");
724        let got = back.fabrics[0]
725            .icac
726            .as_ref()
727            .expect("icac must round-trip as Some");
728        assert_eq!(got.cert.to_tlv().unwrap(), want.cert.to_tlv().unwrap());
729        assert_eq!(got.pkcs8, want.pkcs8);
730    }
731
732    #[test]
733    fn snapshot_without_icac_is_backward_compatible() {
734        // A FabricEntry with icac = None must (a) round-trip to None, and
735        // (b) serialize to bytes containing no C9/C10 tags at all — the
736        // encoding must be identical to what pre-ICAC code produced.
737        let fabric = shared_fabric().clone();
738        assert!(fabric.icac.is_none());
739        let state = ControllerState {
740            fabrics: vec![fabric],
741        };
742
743        let bytes = serialize(&state).expect("serialize");
744        let back = deserialize(&bytes).expect("deserialize");
745        assert!(back.fabrics[0].icac.is_none());
746
747        // Context tags 9 and 10 must not appear anywhere in the fabric
748        // structure's encoded member list. Re-decode the fabric Value
749        // directly and check its tag set rather than grepping raw bytes
750        // (TLV tag/length bytes can coincidentally match arbitrary byte
751        // patterns elsewhere in the blob).
752        let mut r = matter_codec::TlvReader::new(&bytes);
753        let (_tag, root) = r.read_value().expect("read root");
754        let root_members = as_struct(&root).expect("root struct");
755        let fabrics_arr = get(root_members, 1).expect("fabrics array");
756        let first_fabric = &as_array(fabrics_arr).expect("fabrics array")[0];
757        let fabric_members = as_struct(first_fabric).expect("fabric struct");
758        assert!(
759            get(fabric_members, 9).is_none(),
760            "C9 (icac_cert) must be absent when icac is None"
761        );
762        assert!(
763            get(fabric_members, 10).is_none(),
764            "C10 (icac_pkcs8) must be absent when icac is None"
765        );
766    }
767
768    // --- device metadata (vendor_id/product_id/label, C4/C5/C6) ---
769
770    #[test]
771    fn device_metadata_round_trips_and_defaults_none() {
772        // A DeviceEntry with vendor_id/product_id/label set must survive
773        // serialize -> deserialize.
774        let mut fabric = shared_fabric().clone();
775        fabric.devices = vec![DeviceEntry {
776            node_id: 0x1234,
777            peer_noc_public_key: [0x04; 65],
778            resumption_record: None,
779            last_known_addr: None,
780            vendor_id: Some(0xFFF1),
781            product_id: Some(0x8000),
782            label: Some("kitchen plug".to_string()),
783        }];
784        let state = ControllerState {
785            fabrics: vec![fabric],
786        };
787        let bytes = serialize(&state).expect("serialize");
788        let back = deserialize(&bytes).expect("deserialize");
789        let d = &back.fabrics[0].devices[0];
790        assert_eq!(d.vendor_id, Some(0xFFF1));
791        assert_eq!(d.product_id, Some(0x8000));
792        assert_eq!(d.label.as_deref(), Some("kitchen plug"));
793
794        // A device Value::Structure carrying ONLY tags 0+1 (the old layout,
795        // written before vendor_id/product_id/label existed) must
796        // deserialize to all three fields `None` — this is the back-compat
797        // proof for the additive C4/C5/C6 tags.
798        let old_device_val = Value::Structure(vec![
799            (Tag::Context(0), Value::Uint(0x9999)),
800            (Tag::Context(1), Value::Bytes(vec![0x04; 65])),
801        ]);
802        let old_device = device_from_value(&old_device_val).expect("old device must load");
803        assert_eq!(old_device.vendor_id, None);
804        assert_eq!(old_device.product_id, None);
805        assert_eq!(old_device.label, None);
806    }
807
808    #[test]
809    fn old_snapshot_without_t6_t7_loads_with_defaults() {
810        // Simulate a v1 snapshot written by old code: a fabric value that has
811        // only t0..t5 (no t6/t7).  The deserializer must accept it and default
812        // group_keys to [] and outbound_group_counter to 0.
813        let fabric = shared_fabric().clone();
814
815        // Build the fabric Value manually with only t0..t5 (the old layout).
816        let old_fabric_val = Value::Structure(vec![
817            (Tag::Context(0), Value::Uint(fabric.fabric_id)),
818            (Tag::Context(1), Value::Bytes(fabric.ipk.to_vec())),
819            (
820                Tag::Context(2),
821                Value::Bytes(fabric.rcac_cert.to_tlv().expect("rcac tlv")),
822            ),
823            (Tag::Context(3), Value::Bytes(fabric.rcac_pkcs8.clone())),
824            (
825                Tag::Context(4),
826                commissioner_to_value(&fabric.commissioner).expect("commissioner"),
827            ),
828            (Tag::Context(5), Value::Array(vec![])), // no devices
829        ]);
830
831        let root = Value::Structure(vec![
832            (Tag::Context(0), Value::Uint(u64::from(SNAPSHOT_VERSION))),
833            (Tag::Context(1), Value::Array(vec![old_fabric_val])),
834        ]);
835
836        let mut out = Vec::new();
837        let mut w = TlvWriter::new(&mut out);
838        w.write_value(Tag::Anonymous, &root).unwrap();
839
840        let back = deserialize(&out).expect("old snapshot must load without error");
841        assert_eq!(back.fabrics.len(), 1);
842        let f = &back.fabrics[0];
843        assert!(
844            f.group_keys.is_empty(),
845            "group_keys must default to empty for old snapshot"
846        );
847        assert_eq!(
848            f.outbound_group_counter, 0,
849            "outbound_group_counter must default to 0 for old snapshot"
850        );
851    }
852}