/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import type { Behavior } from "#behavior/Behavior.js"; import type { Agent } from "#endpoint/Agent.js"; import type { Endpoint } from "#endpoint/Endpoint.js"; import type { SupportedElements } from "#endpoint/properties/Behaviors.js"; import type { ServerNode } from "#node/ServerNode.js"; import { createPromise, isObject, Logger, MaybePromise, Seconds, withTimeout } from "@matter/general"; import { Access, ClusterModel, Schema } from "@matter/model"; import { OccurrenceManager, Val } from "@matter/protocol"; import type { ClusterType, FabricIndex } from "@matter/types"; import type { ClusterBehavior } from "./ClusterBehavior.js"; const logger = Logger.get("FabricScopedDataHandler"); /** Helper function to iterate over all behaviors of an endpoint */ async function forBehaviors( endpoint: Endpoint, callback: (type: Behavior.Type, cluster: ClusterType, elements: SupportedElements) => Promise, ) { for (const type of Object.values(endpoint.behaviors.supported)) { const cluster = (type as ClusterBehavior.Type)?.cluster; if (!cluster) { continue; } const elements = endpoint.behaviors.elementsOf(type); if (elements.attributes.size || elements.events.size) { await callback(type, cluster, elements); } } } /** * Compute a state patch that strips fabric-scoped attribute entries no longer belonging to an allowed fabric. * Returns `undefined` when nothing needs to change. */ function computeFabricScopedUpdate( endpoint: Endpoint, type: Behavior.Type, supportedAttributes: Set, allowedIndices: FabricIndex[], ): Val.Struct | undefined { // Build a set of fabric-scoped attribute names from the schema const fabricScopedAttrs = new Set(); const schema = Schema(type) as ClusterModel; if (schema.tag === "cluster") { for (const attr of schema.attributes) { if (attr.fabricScoped) { fabricScopedAttrs.add(attr.propertyName); } } } const stateUpdate = {} as Val.Struct; const state = endpoint.stateOf(type) as Val.Struct; for (const attributeName of supportedAttributes) { if (fabricScopedAttrs.has(attributeName)) { const value = state[attributeName]; // If the value contains data for a fabric no longer present, remove that data if (Array.isArray(value) && value.length > 0) { const filtered = value.filter(entry => allowedIndices.includes(entry.fabricIndex)); if (filtered.length !== value.length) { stateUpdate[attributeName] = filtered; } } } } return Object.keys(stateUpdate).length > 0 ? stateUpdate : undefined; } /** * Sanitize fabric-scoped attributes for an already-active endpoint via {@link Endpoint.setStateOf}. * The changed state is returned in an array of promises and should be awaited to ensure the state is updated. */ async function sanitizeAttributeData( endpoint: Endpoint, type: Behavior.Type, cluster: ClusterType, supportedAttributes: Set, allowedIndices: FabricIndex[], ) { const stateUpdate = computeFabricScopedUpdate(endpoint, type, supportedAttributes, allowedIndices); if (stateUpdate === undefined) { return []; } const stateUpdatePromises = new Array>(); // Best-effort during online fabric removal: a per-cluster failure must not abort the removal already in progress. // The startup path (limitEndpointAttributeDataToAllowedFabrics) instead lets failures throw. const { resolver, promise } = createPromise(); (endpoint.eventsOf(type) as Behavior.EventsOf).stateChanged?.on(resolver); try { await endpoint.setStateOf(type, stateUpdate); stateUpdatePromises.push(withTimeout(Seconds(5), promise)); // 5s should be enough for state change } catch (error) { logger.warn( `Could not sanitize fabric-scoped attributes for cluster ${cluster.name} on endpoint ${endpoint.id}`, error, ); } return stateUpdatePromises; } /** * Sanitize Fabric scoped data and events to a list of allowed fabrics. * The logic walks through all endpoints and removes relevant fabric-scoped attribute values for the relevant fabric * from the state. After all state changes are processed, it removes all occurrences of fabric-scoped events. */ export async function limitNodeDataToAllowedFabrics(node: ServerNode, allowedIndices: FabricIndex[]) { const fabricRelevantEvents = new Set(); const stateUpdatePromises = new Array>(); await node.visit(async endpoint => { await forBehaviors(endpoint, async (type, cluster, elements) => { if (elements.attributes.size) { stateUpdatePromises.push( ...(await sanitizeAttributeData(endpoint, type, cluster, elements.attributes, allowedIndices)), ); } if (elements.events.size) { // If we have events also check if they are fabric scoped and collect them const clusterSchema = Schema(type) as ClusterModel; const nsEvents = cluster.events as Record | undefined; if (nsEvents && clusterSchema.tag === "cluster") { // Build set of fabric-scoped event names from schema const fabricScopedEvents = new Set(); for (const event of clusterSchema.events) { const fabric = event.effectiveAccess.fabric; if (fabric === Access.Fabric.Scoped || fabric === Access.Fabric.Sensitive) { fabricScopedEvents.add(event.propertyName); } } for (const eventName of elements.events) { const nsEvent = nsEvents[eventName]; if (nsEvent && fabricScopedEvents.has(eventName)) { fabricRelevantEvents.add(`${(cluster as { id?: number }).id}-${nsEvent.id}`); } } } } }); }); // Wait for all state changed to be executed before processing events await Promise.allSettled(stateUpdatePromises); // Now we can remove all occurrences of fabric-scoped events when payload is bound to this fabric index if (fabricRelevantEvents.size > 0) { const occurrences = node.env.get(OccurrenceManager); for await (const event of occurrences.get()) { if ( fabricRelevantEvents.has(`${event.clusterId}-${event.eventId}`) && isObject(event.payload) && !allowedIndices.includes(event.payload.fabricIndex as FabricIndex) ) { // Remove occurrences of fabric-scoped events const result = occurrences.remove(event.number); if (MaybePromise.is(result)) { await result; } } } } } /** * Sanitize fabric-scoped attributes for a single endpoint while it is still initializing. * * Invoked from {@link EndpointInitializer.behaviorsInitialized}, which runs inside the endpoint's construction * context. The endpoint is not yet active, so {@link Endpoint.setStateOf} (which opens a fresh action that asserts an * active endpoint) would throw. Instead we patch state through the supplied {@link Agent}, whose context is the * construction transaction that commits when initialization completes. */ export async function limitEndpointAttributeDataToAllowedFabrics(agent: Agent, allowedIndices: FabricIndex[]) { await forBehaviors(agent.endpoint, async (type, _cluster, elements) => { if (!elements.attributes.size) { return; } const stateUpdate = computeFabricScopedUpdate(agent.endpoint, type, elements.attributes, allowedIndices); if (stateUpdate === undefined) { return; } const behavior = agent.get(type); behavior.type.supervisor.patch(stateUpdate, behavior.state, agent.endpoint.path); }); }