Skip to main content

matter_controller/
opcreds.rs

1//! `OperationalCredentials` (0x003E) controller support: types and the pure
2//! Value codecs the fabric-management `Node` verbs compose. See M9-D2 plan.
3
4use crate::error::Error;
5use matter_codec::{Tag, Value};
6use matter_interaction::AttributePath;
7
8/// Cluster ID for `OperationalCredentials` (Matter spec §11.18).
9pub(crate) const OPERATIONAL_CREDENTIALS_CLUSTER: u32 = 0x003E;
10/// Command id for `UpdateFabricLabel` (§11.18.6.9).
11pub(crate) const CMD_UPDATE_FABRIC_LABEL: u32 = 0x09;
12/// Command id for `RemoveFabric` (§11.18.6.10).
13pub(crate) const CMD_REMOVE_FABRIC: u32 = 0x0A;
14/// Attribute id for `Fabrics` (§11.18.4.5).
15pub(crate) const ATTR_FABRICS: u32 = 0x0001;
16/// Attribute id for `CurrentFabricIndex` (§11.18.4.6).
17pub(crate) const ATTR_CURRENT_FABRIC_INDEX: u32 = 0x0005;
18
19// FabricDescriptorStruct context tags (Matter spec §11.18.4.5).
20const TAG_ROOT_PUBLIC_KEY: u8 = 1;
21const TAG_VENDOR_ID: u8 = 2;
22const TAG_FABRIC_ID: u8 = 3;
23const TAG_NODE_ID: u8 = 4;
24const TAG_LABEL: u8 = 5;
25const TAG_FABRIC_INDEX: u8 = 254;
26
27// NOCResponse context tags (Matter spec §11.18.5.10).
28const TAG_NOC_STATUS: u8 = 0;
29const TAG_NOC_FABRIC_INDEX: u8 = 1;
30const TAG_NOC_DEBUG_TEXT: u8 = 2;
31
32/// One fabric a device belongs to (a decoded `FabricDescriptorStruct`).
33///
34/// Parsed from the `Fabrics` attribute (0x0001) of the `OperationalCredentials`
35/// cluster (0x003E). Unknown fields from the wire are silently ignored —
36/// `#[non_exhaustive]` lets us add fields without a breaking change.
37#[derive(Clone, Debug, PartialEq, Eq)]
38#[non_exhaustive]
39pub struct FabricDescriptor {
40    /// Fabric's root public key (SEC1 uncompressed P-256, 65 bytes).
41    pub root_public_key: Vec<u8>,
42    /// Vendor ID of the admin that created this fabric.
43    pub vendor_id: u16,
44    /// 64-bit Fabric ID.
45    pub fabric_id: u64,
46    /// The device's node ID on this fabric.
47    pub node_id: u64,
48    /// Operator-assigned label (may be empty).
49    pub label: String,
50    /// The device-assigned fabric index (1-based, device-local).
51    pub fabric_index: u8,
52}
53
54/// Decoded `NOCResponse` command fields.
55///
56/// Returned by the device after an `AddNOC`, `UpdateNOC`, `UpdateFabricLabel`,
57/// or `RemoveFabric` command (Matter spec §11.18.5.10).
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub(crate) struct NocStatus {
60    /// `NodeOperationalCertStatusEnum` — 0 = OK.
61    pub status: u8,
62    /// The fabric index affected (present on success).
63    pub fabric_index: Option<u8>,
64    /// Optional human-readable diagnostic text.
65    pub debug_text: Option<String>,
66}
67
68fn struct_members(v: &Value) -> Option<&[(Tag, Value)]> {
69    match v {
70        Value::Structure(m) | Value::List(m) => Some(m),
71        _ => None,
72    }
73}
74
75fn ctx(members: &[(Tag, Value)], tag: u8) -> Option<&Value> {
76    members
77        .iter()
78        .find(|(t, _)| *t == Tag::Context(tag))
79        .map(|(_, v)| v)
80}
81
82fn parse_fabric_descriptor(v: &Value) -> Option<FabricDescriptor> {
83    let m = struct_members(v)?;
84    #[allow(clippy::cast_possible_truncation)]
85    // FabricDescriptorStruct fields are spec-typed: VendorId=uint16, FabricIndex=uint8.
86    // The device MUST send values within range; truncation to the spec width is correct.
87    Some(FabricDescriptor {
88        root_public_key: match ctx(m, TAG_ROOT_PUBLIC_KEY)? {
89            Value::Bytes(b) => b.clone(),
90            _ => return None,
91        },
92        vendor_id: match ctx(m, TAG_VENDOR_ID)? {
93            Value::Uint(u) => *u as u16,
94            _ => return None,
95        },
96        fabric_id: match ctx(m, TAG_FABRIC_ID)? {
97            Value::Uint(u) => *u,
98            _ => return None,
99        },
100        node_id: match ctx(m, TAG_NODE_ID)? {
101            Value::Uint(u) => *u,
102            _ => return None,
103        },
104        label: match ctx(m, TAG_LABEL) {
105            Some(Value::Utf8(s)) => s.clone(),
106            _ => String::new(),
107        },
108        fabric_index: match ctx(m, TAG_FABRIC_INDEX)? {
109            Value::Uint(u) => *u as u8,
110            _ => return None,
111        },
112    })
113}
114
115/// Parse the `Fabrics` list attribute into descriptors. Malformed entries are skipped.
116///
117/// Returns an empty `Vec` when the attribute is absent or contains no decodable
118/// entries (infallible).
119pub(crate) fn parse_fabrics(reports: &[(AttributePath, Value)]) -> Vec<FabricDescriptor> {
120    for (path, value) in reports {
121        if path.attribute == ATTR_FABRICS {
122            if let Value::Array(items) = value {
123                return items.iter().filter_map(parse_fabric_descriptor).collect();
124            }
125        }
126    }
127    Vec::new()
128}
129
130/// Parse `CurrentFabricIndex` from a read result.
131///
132/// Returns `None` when the attribute is absent or has an unexpected type.
133pub(crate) fn parse_current_fabric_index(reports: &[(AttributePath, Value)]) -> Option<u8> {
134    for (path, value) in reports {
135        if path.attribute == ATTR_CURRENT_FABRIC_INDEX {
136            if let Value::Uint(u) = value {
137                #[allow(clippy::cast_possible_truncation)]
138                // CurrentFabricIndex is spec-typed as fabric-idx (uint8); truncation correct.
139                return Some(*u as u8);
140            }
141        }
142    }
143    None
144}
145
146/// Parse a `NOCResponse` command-fields struct.
147///
148/// Returns a sentinel `NocStatus { status: u8::MAX, .. }` when `fields` is not
149/// a struct or the status tag is missing.
150pub(crate) fn parse_noc_response(fields: &Value) -> NocStatus {
151    let m = struct_members(fields).unwrap_or(&[]);
152    #[allow(clippy::cast_possible_truncation)]
153    // NOCResponse fields are spec-typed: StatusCode=enum8, FabricIndex=uint8.
154    // Truncation to u8 is correct for all valid wire values.
155    NocStatus {
156        status: match ctx(m, TAG_NOC_STATUS) {
157            Some(Value::Uint(u)) => *u as u8,
158            _ => u8::MAX,
159        },
160        fabric_index: match ctx(m, TAG_NOC_FABRIC_INDEX) {
161            Some(Value::Uint(u)) => Some(*u as u8),
162            _ => None,
163        },
164        debug_text: match ctx(m, TAG_NOC_DEBUG_TEXT) {
165            Some(Value::Utf8(s)) => Some(s.clone()),
166            _ => None,
167        },
168    }
169}
170
171/// Map a `NocStatus` to `Ok(())` on success (status 0) or a rejection error.
172///
173/// # Errors
174///
175/// Returns [`Error::OperationalCredentialsRejected`] when `s.status != 0`.
176pub(crate) fn noc_status_to_result(s: &NocStatus) -> Result<(), Error> {
177    if s.status == 0 {
178        Ok(())
179    } else {
180        Err(Error::OperationalCredentialsRejected(s.status))
181    }
182}
183
184#[cfg(test)]
185#[allow(clippy::unwrap_used)] // Test code: CLAUDE.md test-code carve-out.
186mod tests {
187    use super::*;
188
189    fn ap(a: u32) -> AttributePath {
190        AttributePath {
191            endpoint: 0,
192            cluster: OPERATIONAL_CREDENTIALS_CLUSTER,
193            attribute: a,
194        }
195    }
196
197    fn fabric_struct(idx: u8, fid: u64, label: &str) -> Value {
198        Value::Structure(vec![
199            (
200                Tag::Context(TAG_ROOT_PUBLIC_KEY),
201                Value::Bytes(vec![4u8; 65]),
202            ),
203            (Tag::Context(TAG_VENDOR_ID), Value::Uint(0xFFF1)),
204            (Tag::Context(TAG_FABRIC_ID), Value::Uint(fid)),
205            (Tag::Context(TAG_NODE_ID), Value::Uint(0x1122_3344)),
206            (Tag::Context(TAG_LABEL), Value::Utf8(label.into())),
207            (Tag::Context(TAG_FABRIC_INDEX), Value::Uint(u64::from(idx))),
208        ])
209    }
210
211    #[test]
212    fn parse_fabrics_decodes_array_of_structs() {
213        let reports = vec![(
214            ap(ATTR_FABRICS),
215            Value::Array(vec![
216                fabric_struct(1, 100, "home"),
217                fabric_struct(2, 200, ""),
218            ]),
219        )];
220        let f = parse_fabrics(&reports);
221        assert_eq!(f.len(), 2);
222        assert_eq!(f[0].fabric_index, 1);
223        assert_eq!(f[0].fabric_id, 100);
224        assert_eq!(f[0].label, "home");
225        assert_eq!(f[0].root_public_key.len(), 65);
226        assert_eq!(f[1].fabric_index, 2);
227        assert_eq!(f[1].label, "");
228    }
229
230    #[test]
231    fn parse_current_fabric_index_reads_u8() {
232        let reports = vec![(ap(ATTR_CURRENT_FABRIC_INDEX), Value::Uint(3))];
233        assert_eq!(parse_current_fabric_index(&reports), Some(3));
234        assert_eq!(parse_current_fabric_index(&[]), None);
235    }
236
237    #[test]
238    fn noc_response_success_and_failure() {
239        let ok = Value::Structure(vec![
240            (Tag::Context(0), Value::Uint(0)),
241            (Tag::Context(1), Value::Uint(2)),
242        ]);
243        let s = parse_noc_response(&ok);
244        assert_eq!(s.status, 0);
245        assert_eq!(s.fabric_index, Some(2));
246        assert!(noc_status_to_result(&s).is_ok());
247
248        let bad = Value::Structure(vec![(Tag::Context(0), Value::Uint(7))]);
249        let s2 = parse_noc_response(&bad);
250        assert!(matches!(
251            noc_status_to_result(&s2),
252            Err(Error::OperationalCredentialsRejected(7))
253        ));
254    }
255}