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 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 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 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 matter_cert::MatterTime;
389    use matter_commissioning::SystemNocRng;
390
391    fn sample_state() -> ControllerState {
392        let cfg = FabricConfig {
393            fabric_id: 0x1122_3344_5566_7788,
394            rcac_id: 1,
395            commissioner_node_id: 0x0000_0000_0000_0001,
396            validity: (
397                MatterTime::from_unix_secs(1_700_000_000),
398                MatterTime::NO_EXPIRY,
399            ),
400            issue_icac: false,
401        };
402        let mut fabric = create_fabric(&cfg, &SystemNocRng).expect("create_fabric");
403        fabric.devices.push(DeviceEntry {
404            node_id: 0xABCD,
405            peer_noc_public_key: [0x04; 65],
406            resumption_record: Some(vec![1, 2, 3, 4]),
407            last_known_addr: Some("[fe80::1]:5540".to_string()),
408            vendor_id: None,
409            product_id: None,
410            label: None,
411        });
412        fabric.devices.push(DeviceEntry {
413            node_id: 0xBEEF,
414            peer_noc_public_key: [0x04; 65],
415            resumption_record: None,
416            last_known_addr: None,
417            vendor_id: None,
418            product_id: None,
419            label: None,
420        });
421        ControllerState {
422            fabrics: vec![fabric],
423        }
424    }
425
426    #[test]
427    fn round_trips_a_full_state() {
428        let state = sample_state();
429        let bytes = serialize(&state).expect("serialize");
430        let back = deserialize(&bytes).expect("deserialize");
431
432        assert_eq!(back.fabrics.len(), 1);
433        let (a, b) = (&state.fabrics[0], &back.fabrics[0]);
434        assert_eq!(a.fabric_id, b.fabric_id);
435        assert_eq!(a.ipk, b.ipk);
436        assert_eq!(a.rcac_pkcs8, b.rcac_pkcs8);
437        assert_eq!(a.rcac_cert.to_tlv().unwrap(), b.rcac_cert.to_tlv().unwrap());
438        assert_eq!(a.commissioner.node_id, b.commissioner.node_id);
439        assert_eq!(
440            a.commissioner.operational_pkcs8,
441            b.commissioner.operational_pkcs8
442        );
443        assert_eq!(
444            a.commissioner.noc.to_tlv().unwrap(),
445            b.commissioner.noc.to_tlv().unwrap()
446        );
447        assert_eq!(a.devices.len(), b.devices.len());
448        assert_eq!(a.devices[0].node_id, b.devices[0].node_id);
449        assert_eq!(
450            a.devices[0].resumption_record,
451            b.devices[0].resumption_record
452        );
453        assert_eq!(a.devices[0].last_known_addr, b.devices[0].last_known_addr);
454        assert_eq!(a.devices[1].resumption_record, None);
455        assert_eq!(a.devices[1].last_known_addr, None);
456    }
457
458    #[test]
459    fn empty_state_round_trips() {
460        let bytes = serialize(&ControllerState::default()).expect("serialize");
461        assert!(deserialize(&bytes).expect("deserialize").fabrics.is_empty());
462    }
463
464    #[test]
465    fn rejects_unknown_version() {
466        // Hand-build a root with version 99.
467        let root = Value::Structure(vec![
468            (Tag::Context(0), Value::Uint(99)),
469            (Tag::Context(1), Value::Array(vec![])),
470        ]);
471        let mut out = Vec::new();
472        let mut w = TlvWriter::new(&mut out);
473        w.write_value(Tag::Anonymous, &root).unwrap();
474        let err = deserialize(&out).expect_err("must reject");
475        assert!(matches!(err, Error::Snapshot(_)));
476    }
477
478    // --- property-based round-trip (CLAUDE.md: encoders get a proptest) ---
479
480    use proptest::prelude::*;
481    use std::sync::OnceLock;
482
483    /// Mint one real fabric and reuse it across all proptest cases — key
484    /// generation is expensive, but cloning a `FabricEntry` is cheap.
485    fn shared_fabric() -> &'static FabricEntry {
486        static FABRIC: OnceLock<FabricEntry> = OnceLock::new();
487        FABRIC.get_or_init(|| {
488            let cfg = FabricConfig {
489                fabric_id: 0x0102_0304_0506_0708,
490                rcac_id: 1,
491                commissioner_node_id: 0x0000_0000_0000_0001,
492                validity: (
493                    MatterTime::from_unix_secs(1_700_000_000),
494                    MatterTime::NO_EXPIRY,
495                ),
496                issue_icac: false,
497            };
498            create_fabric(&cfg, &SystemNocRng).expect("mint shared fabric")
499        })
500    }
501
502    prop_compose! {
503        fn arb_device()(
504            node_id in any::<u64>(),
505            pk in prop::collection::vec(any::<u8>(), 65),
506            rr in prop::option::of(prop::collection::vec(any::<u8>(), 0..40)),
507            addr in prop::option::of("[ -~]{0,32}"),
508        ) -> DeviceEntry {
509            let mut peer_noc_public_key = [0u8; 65];
510            peer_noc_public_key.copy_from_slice(&pk);
511            DeviceEntry {
512                node_id,
513                peer_noc_public_key,
514                resumption_record: rr,
515                last_known_addr: addr,
516                vendor_id: None,
517                product_id: None,
518                label: None,
519            }
520        }
521    }
522
523    proptest! {
524        /// `deserialize(serialize(state)) == state` for arbitrary device lists.
525        #[test]
526        fn snapshot_round_trips(devices in prop::collection::vec(arb_device(), 0..6)) {
527            let mut fabric = shared_fabric().clone();
528            fabric.devices = devices.clone();
529            let state = ControllerState { fabrics: vec![fabric] };
530
531            let bytes = serialize(&state).expect("serialize");
532            let back = deserialize(&bytes).expect("deserialize");
533
534            prop_assert_eq!(back.fabrics.len(), 1);
535            let dev_back = &back.fabrics[0].devices;
536            prop_assert_eq!(dev_back.len(), devices.len());
537            for (a, b) in devices.iter().zip(dev_back.iter()) {
538                prop_assert_eq!(a.node_id, b.node_id);
539                prop_assert_eq!(a.peer_noc_public_key, b.peer_noc_public_key);
540                prop_assert_eq!(&a.resumption_record, &b.resumption_record);
541                prop_assert_eq!(&a.last_known_addr, &b.last_known_addr);
542            }
543        }
544    }
545
546    // --- group-key persistence tests ---
547
548    #[test]
549    fn group_keys_round_trip() {
550        // A FabricEntry WITH group_keys and a non-zero counter must survive
551        // serialize → deserialize with all group fields preserved.
552        let mut fabric = shared_fabric().clone();
553        fabric.group_keys = vec![
554            GroupKeySetConfig::new(0x0001, [0xAA; 16], 1_700_000_000),
555            GroupKeySetConfig::new(0x0002, [0xBB; 16], 1_700_100_000),
556        ];
557        fabric.outbound_group_counter = 42;
558        let state = ControllerState {
559            fabrics: vec![fabric.clone()],
560        };
561
562        let bytes = serialize(&state).expect("serialize");
563        let back = deserialize(&bytes).expect("deserialize");
564
565        assert_eq!(back.fabrics.len(), 1);
566        let f = &back.fabrics[0];
567        assert_eq!(f.outbound_group_counter, 42);
568        assert_eq!(f.group_keys.len(), 2);
569        assert_eq!(f.group_keys[0].key_set_id, 0x0001);
570        assert_eq!(f.group_keys[0].epoch_key, [0xAA; 16]);
571        assert_eq!(f.group_keys[0].epoch_start_time, 1_700_000_000);
572        assert_eq!(f.group_keys[1].key_set_id, 0x0002);
573        assert_eq!(f.group_keys[1].epoch_key, [0xBB; 16]);
574        assert_eq!(f.group_keys[1].epoch_start_time, 1_700_100_000);
575    }
576
577    #[test]
578    fn icd_clients_round_trip() {
579        // A FabricEntry WITH ICD registrations must survive serialize →
580        // deserialize with all fields preserved (additive t8, no version bump).
581        let mut fabric = shared_fabric().clone();
582        fabric.icd_clients = vec![
583            crate::icd::IcdRegistration::new(0x0042, 1, 1, [0xCC; 16], 7),
584            crate::icd::IcdRegistration::new(0x0043, 1, 2, [0xDD; 16], 99),
585        ];
586        let state = ControllerState {
587            fabrics: vec![fabric.clone()],
588        };
589        let bytes = serialize(&state).expect("serialize");
590        let back = deserialize(&bytes).expect("deserialize");
591        assert_eq!(back.fabrics[0].icd_clients, fabric.icd_clients);
592    }
593
594    // --- ICAC persistence (C9/C10) ---
595
596    /// Build a throwaway ICAC cert (signed by `fabric`'s RCAC key) + a fresh
597    /// PKCS#8 signing key for it, wrapped as an [`IcacIdentity`].
598    fn sample_icac(fabric: &FabricEntry) -> crate::state::IcacIdentity {
599        use matter_cert::operational::{icac, sign_with_ring, IcacParams};
600        use matter_cert::PublicKey;
601        use matter_crypto::{RingSigner, Signer};
602
603        let (icac_signer, icac_pkcs8) = RingSigner::generate().expect("generate icac key");
604        let icac_public_key =
605            PublicKey::new(*icac_signer.public_key().as_bytes()).expect("valid P-256 public key");
606        let issuer_skid = fabric
607            .rcac_cert
608            .extensions()
609            .subject_key_identifier
610            .expect("rcac has SKID");
611
612        let unsigned = icac(IcacParams::new(
613            0x0000_0000_0000_0099,
614            fabric.rcac_cert.subject().clone(),
615            issuer_skid,
616            icac_public_key,
617            vec![0x01],
618            MatterTime::from_unix_secs(1_700_000_000),
619            MatterTime::NO_EXPIRY,
620        ))
621        .expect("build unsigned icac");
622        let cert = sign_with_ring(unsigned, &fabric.rcac_pkcs8).expect("sign icac");
623
624        crate::state::IcacIdentity {
625            cert,
626            pkcs8: icac_pkcs8,
627        }
628    }
629
630    #[test]
631    fn snapshot_round_trips_fabric_with_icac() {
632        // A FabricEntry WITH an ICAC must survive serialize -> deserialize
633        // with both the cert and the signing key preserved (C9/C10).
634        let mut fabric = shared_fabric().clone();
635        let icac_identity = sample_icac(&fabric);
636        fabric.icac = Some(icac_identity);
637
638        let state = ControllerState {
639            fabrics: vec![fabric.clone()],
640        };
641        let bytes = serialize(&state).expect("serialize");
642        let back = deserialize(&bytes).expect("deserialize");
643
644        let want = fabric.icac.as_ref().expect("icac set on input fabric");
645        let got = back.fabrics[0]
646            .icac
647            .as_ref()
648            .expect("icac must round-trip as Some");
649        assert_eq!(got.cert.to_tlv().unwrap(), want.cert.to_tlv().unwrap());
650        assert_eq!(got.pkcs8, want.pkcs8);
651    }
652
653    #[test]
654    fn snapshot_without_icac_is_backward_compatible() {
655        // A FabricEntry with icac = None must (a) round-trip to None, and
656        // (b) serialize to bytes containing no C9/C10 tags at all — the
657        // encoding must be identical to what pre-ICAC code produced.
658        let fabric = shared_fabric().clone();
659        assert!(fabric.icac.is_none());
660        let state = ControllerState {
661            fabrics: vec![fabric],
662        };
663
664        let bytes = serialize(&state).expect("serialize");
665        let back = deserialize(&bytes).expect("deserialize");
666        assert!(back.fabrics[0].icac.is_none());
667
668        // Context tags 9 and 10 must not appear anywhere in the fabric
669        // structure's encoded member list. Re-decode the fabric Value
670        // directly and check its tag set rather than grepping raw bytes
671        // (TLV tag/length bytes can coincidentally match arbitrary byte
672        // patterns elsewhere in the blob).
673        let mut r = matter_codec::TlvReader::new(&bytes);
674        let (_tag, root) = r.read_value().expect("read root");
675        let root_members = as_struct(&root).expect("root struct");
676        let fabrics_arr = get(root_members, 1).expect("fabrics array");
677        let first_fabric = &as_array(fabrics_arr).expect("fabrics array")[0];
678        let fabric_members = as_struct(first_fabric).expect("fabric struct");
679        assert!(
680            get(fabric_members, 9).is_none(),
681            "C9 (icac_cert) must be absent when icac is None"
682        );
683        assert!(
684            get(fabric_members, 10).is_none(),
685            "C10 (icac_pkcs8) must be absent when icac is None"
686        );
687    }
688
689    // --- device metadata (vendor_id/product_id/label, C4/C5/C6) ---
690
691    #[test]
692    fn device_metadata_round_trips_and_defaults_none() {
693        // A DeviceEntry with vendor_id/product_id/label set must survive
694        // serialize -> deserialize.
695        let mut fabric = shared_fabric().clone();
696        fabric.devices = vec![DeviceEntry {
697            node_id: 0x1234,
698            peer_noc_public_key: [0x04; 65],
699            resumption_record: None,
700            last_known_addr: None,
701            vendor_id: Some(0xFFF1),
702            product_id: Some(0x8000),
703            label: Some("kitchen plug".to_string()),
704        }];
705        let state = ControllerState {
706            fabrics: vec![fabric],
707        };
708        let bytes = serialize(&state).expect("serialize");
709        let back = deserialize(&bytes).expect("deserialize");
710        let d = &back.fabrics[0].devices[0];
711        assert_eq!(d.vendor_id, Some(0xFFF1));
712        assert_eq!(d.product_id, Some(0x8000));
713        assert_eq!(d.label.as_deref(), Some("kitchen plug"));
714
715        // A device Value::Structure carrying ONLY tags 0+1 (the old layout,
716        // written before vendor_id/product_id/label existed) must
717        // deserialize to all three fields `None` — this is the back-compat
718        // proof for the additive C4/C5/C6 tags.
719        let old_device_val = Value::Structure(vec![
720            (Tag::Context(0), Value::Uint(0x9999)),
721            (Tag::Context(1), Value::Bytes(vec![0x04; 65])),
722        ]);
723        let old_device = device_from_value(&old_device_val).expect("old device must load");
724        assert_eq!(old_device.vendor_id, None);
725        assert_eq!(old_device.product_id, None);
726        assert_eq!(old_device.label, None);
727    }
728
729    #[test]
730    fn old_snapshot_without_t6_t7_loads_with_defaults() {
731        // Simulate a v1 snapshot written by old code: a fabric value that has
732        // only t0..t5 (no t6/t7).  The deserializer must accept it and default
733        // group_keys to [] and outbound_group_counter to 0.
734        let fabric = shared_fabric().clone();
735
736        // Build the fabric Value manually with only t0..t5 (the old layout).
737        let old_fabric_val = Value::Structure(vec![
738            (Tag::Context(0), Value::Uint(fabric.fabric_id)),
739            (Tag::Context(1), Value::Bytes(fabric.ipk.to_vec())),
740            (
741                Tag::Context(2),
742                Value::Bytes(fabric.rcac_cert.to_tlv().expect("rcac tlv")),
743            ),
744            (Tag::Context(3), Value::Bytes(fabric.rcac_pkcs8.clone())),
745            (
746                Tag::Context(4),
747                commissioner_to_value(&fabric.commissioner).expect("commissioner"),
748            ),
749            (Tag::Context(5), Value::Array(vec![])), // no devices
750        ]);
751
752        let root = Value::Structure(vec![
753            (Tag::Context(0), Value::Uint(u64::from(SNAPSHOT_VERSION))),
754            (Tag::Context(1), Value::Array(vec![old_fabric_val])),
755        ]);
756
757        let mut out = Vec::new();
758        let mut w = TlvWriter::new(&mut out);
759        w.write_value(Tag::Anonymous, &root).unwrap();
760
761        let back = deserialize(&out).expect("old snapshot must load without error");
762        assert_eq!(back.fabrics.len(), 1);
763        let f = &back.fabrics[0];
764        assert!(
765            f.group_keys.is_empty(),
766            "group_keys must default to empty for old snapshot"
767        );
768        assert_eq!(
769            f.outbound_group_counter, 0,
770            "outbound_group_counter must default to 0 for old snapshot"
771        );
772    }
773}