/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { camelize, Diagnostic, InternalError, Logger } from "@matter/general"; import type { Schema } from "@matter/model"; import { AttributeModel, ClusterModel, DataModelPath, FeatureMap, FieldModel, Metatype, ValueModel, } from "@matter/model"; import { ConformanceError, DatatypeError, hasLocalActor, SchemaImplementationError, Val } from "@matter/protocol"; import { BitmapEncodedValue, FabricIndex, Status } from "@matter/types"; import { RootSupervisor } from "../../supervision/RootSupervisor.js"; import { maybeConfigOf } from "../../supervision/SupervisionConfig.js"; import type { ValueSupervisor } from "../../supervision/ValueSupervisor.js"; import { memberSlotOf, memberValueOf } from "../managed/MemberKeys.js"; import { assertArray, assertBoolean, assertBytes, assertInt, assertNumber, assertNumeric, assertObject, assertString, } from "./assertions.js"; import { astToFunction } from "./conformance-compiler.js"; import { createConformanceValidator } from "./conformance.js"; import { createConstraintValidator } from "./constraint.js"; import { isFabricIndexSentinel } from "./FabricIndexSentinel.js"; import { ValidationLocation } from "./location.js"; import { forwardValidationToPeer } from "./peer-forwarding.js"; const logger = Logger.get("ValueValidator"); /** * Wrap a datatype validator so peer (client) writes forward datatype violations the device may still accept (see * {@link forwardValidationToPeer}). Conformance violations are forwarded separately in {@link createConformanceValidator} * so that datatype validation still runs after a forwarded conformance failure. */ function forwardableForClientPeer(inner: ValueSupervisor.Validate): ValueSupervisor.Validate { return (value, session, location) => { try { inner(value, session, location); } catch (e) { if (!forwardValidationToPeer(session, e)) { throw e; } } }; } /** * Generate a function that performs data validation. * * @param schema the schema against which we validate * @param supervisor used to retrieve validators for sub-properties */ export function ValueValidator(schema: Schema, supervisor: RootSupervisor): ValueSupervisor.Validate | undefined { if (schema instanceof ClusterModel) { return forwardableForClientPeer(createStructValidator(schema, supervisor)); } let validator: ValueSupervisor.Validate | undefined; const metabase = schema.metabase; switch (metabase?.metatype) { case Metatype.enum: validator = createEnumValidator(schema, supervisor); break; case Metatype.bitmap: validator = createBitmapValidator(schema, supervisor); break; case Metatype.integer: validator = createIntegerValidator(schema, supervisor, metabase.name); break; case Metatype.float: validator = createSimpleValidator(schema, supervisor, assertNumeric); break; case Metatype.boolean: validator = createSimpleValidator(schema, supervisor, assertBoolean); break; case Metatype.string: validator = createSimpleValidator(schema, supervisor, assertString); break; case Metatype.bytes: validator = createSimpleValidator(schema, supervisor, assertBytes); break; case Metatype.object: validator = createStructValidator(schema, supervisor); break; case Metatype.array: validator = createListValidator(schema, supervisor); break; // A duration is held as a number of milliseconds, so a bound the specification states on one applies as it // does to any other magnitude case Metatype.duration: validator = createSimpleValidator(schema, supervisor, assertNumeric); break; case Metatype.date: case Metatype.any: break; case undefined: const type = schema.effectiveType; if (type === undefined) { if (schema.isDisallowed || ((schema.isDeprecated || schema.isObsolete) && !schema.type)) { // We do not need to validate types for disallowed members and the specification may not include // them break; } throw new SchemaImplementationError(new DataModelPath(schema.path), `No type defined`); } throw new SchemaImplementationError( new DataModelPath(schema.path), `Cannot determine metatype for type "${type}"`, ); default: throw new SchemaImplementationError( new DataModelPath((schema as unknown as Schema).path), `Unsupported validation metatype ${metabase?.metatype}`, ); } validator = createNullValidator(schema, validator); validator = validator && forwardableForClientPeer(validator); validator = createConformanceValidator(schema, supervisor, validator); return validator; } function createNullValidator( schema: ValueModel, nextValidator?: ValueSupervisor.Validate, ): ValueSupervisor.Validate | undefined { if (schema.effectiveQuality.nullable === true) { return (value, options, location) => { if (value !== null) { nextValidator?.(value, options, location); } }; } // If the field is not nullable, let the datatype check handle validation return nextValidator; } function createEnumValidator(schema: ValueModel, supervisor: RootSupervisor): ValueSupervisor.Validate | undefined { // A value of an enumerated type is its effective ID, which is its position among its siblings where the // definition states none, or lies in the range of a range member const members = supervisor.membersOf(schema); const valid = new Set(members.map(member => member.effectiveId).filter(e => e !== undefined)); const ranges = members.filter(member => member instanceof FieldModel && member.isEnumRange); const constraintValidator = createConstraintValidator(schema.effectiveConstraint, schema, supervisor); return (value, session, location) => { assertNumber(value, location); if (!valid.has(value) && !ranges.some(range => range.constraint.test(value))) { throw new DatatypeError(location, "defined in enum", value, Status.ConstraintError); } constraintValidator?.(value, session, location); }; } /** * OR the bits [start, start+length) into a 32-bit mask. Returns undefined (disabling reserved-bit enforcement) if the * range cannot be represented in a 32-bit mask, since JS bitwise operators are 32-bit. */ function addBitsToMask(mask: number | undefined, start: number, length: number): number | undefined { if (mask === undefined || start < 0 || length <= 0 || start + length > 32) { return undefined; } const lowMask = length >= 32 ? 0xffffffff : 2 ** length - 1; return (mask | ((lowMask << start) >>> 0)) >>> 0; } function createBitmapValidator(schema: ValueModel, supervisor: RootSupervisor): ValueSupervisor.Validate | undefined { const fields = {} as Record< string, { schema: ValueModel; max: number; bit?: number; conformance?: ValueSupervisor.Validate | undefined } >; // Union of every bit position covered by a defined field. Any bit set outside this mask in the encoded value is // reserved and may not be written (Matter spec: reserved bitmap bits SHALL be 0). Decode discards reserved bits, // so we recover them from BitmapEncodedValue. Left undefined if any field's bit range cannot be determined, in // which case we skip reserved-bit enforcement rather than risk false rejections. let definedMask: number | undefined = 0; for (const field of supervisor.membersOf(schema)) { const constraint = field.effectiveConstraint; let max; let bit; if (typeof constraint.min === "number" && typeof constraint.max === "number") { max = Math.pow(2, constraint.max - constraint.min + 1) - 1; // e.g bits 0..2 -> 2^3 - 1 = 7 aka 111b bit = constraint.min; definedMask = addBitsToMask(definedMask, constraint.min, constraint.max - constraint.min + 1); } else { max = 1; if (typeof constraint.value === "number") { bit = constraint.value; definedMask = addBitsToMask(definedMask, constraint.value, 1); } else { definedMask = undefined; } } let name; let conformance; if (field?.parent?.id === FeatureMap.id) { // The value of a feature map is the feature selection itself, so judging its bits against the features it // states says nothing; FeatureSelectionErrors() assesses a selection name = camelize(field.title ?? field.name); } else { name = field.propertyName; try { conformance = astToFunction(field, supervisor, { refusalOnly: true }); } catch (e) { // A conformance the compiler cannot read is one we cannot enforce; refusing to supervise the cluster at // all would be worse than leaving the bit unjudged if (!(e instanceof SchemaImplementationError)) { throw e; } logger.debug(`Cannot enforce conformance of ${field.path}:`, Diagnostic.errorMessage(e)); } } fields[name] = { schema: field, max, bit, conformance, }; } const validateMagnitude = createConstraintValidator(schema.effectiveConstraint, schema, supervisor); return (value, session, location) => { assertObject(value, location); // Structural per-field checks run before the checks below. The reserved-bit check is CONSTRAINT_ERROR-coded // and therefore forwarded for peer writes, so it comes last or a forwarded failure would skip the structural // validation that must always fail fast. for (const key in value) { const field = fields[key]; const subpath = location.path.at(key); if (field === undefined) { throw new DatatypeError(subpath, "defined in bitmap", key); } const fieldValue = value[key]; if (fieldValue === undefined) { continue; } if (field.max === 1) { assertBoolean(fieldValue, subpath); } else { assertNumber(fieldValue, subpath); if (fieldValue > field.max) { throw new DatatypeError(subpath, "in range of bit field", fieldValue); } } } // The bound stays local, so it runs with the structural checks: a conformance or reserved-bit failure is // forwarded for a peer write, which unwinds the validator before anything after it validateMagnitude?.(value, session, location); // A bit's conformance says whether the bit may be set, never that it must be, because every declared bit is // part of the value whether set or clear — so only a bit this value sets is judged. Enforced only where no // remote subject authored the value: a peer states its own capabilities, and refusing its write would deny // interop with a device whose conformance disagrees with our model. Runs after the structural checks because // a ConformanceError is forwarded for a peer write, which unwinds the validator if (hasLocalActor(session) && location.config?.supervision?.conformance !== false) { for (const key in value) { const field = fields[key]; const fieldValue = value[key]; if (field?.conformance === undefined || !fieldValue) { continue; } field.conformance(fieldValue, session, { ...location, path: location.path.at(key) }); } } if (definedMask !== undefined) { const encoded = (value as Record)[BitmapEncodedValue]; if (typeof encoded === "number" && (encoded & ~definedMask) >>> 0 !== 0) { throw new DatatypeError(location, "free of reserved bits", encoded, Status.ConstraintError); } } }; } function createSimpleValidator( schema: ValueModel, supervisor: RootSupervisor, validateType: (value: Val, location: ValidationLocation) => void, ): ValueSupervisor.Validate { const validateConstraint = createConstraintValidator(schema.effectiveConstraint, schema, supervisor); return (value, session, location) => { // If undefined, only conformance tests apply if (value === undefined) { return; } validateType(value, location); validateConstraint?.(value, session, location); }; } function createIntegerValidator(schema: ValueModel, supervisor: RootSupervisor, name: string) { const nullable = schema.effectiveQuality.nullable; let assertion; // Let's check schema type specific assertations first // TODO maybe introduce a date type and then optimize this here later if (schema.type === "epoch-s" || schema.type === "epoch-us") { if (nullable) { assertion = assertInt.nullable[schema.type]; } else { assertion = assertInt.notNullable[schema.type]; } } if (assertion === undefined) { if (nullable) { assertion = assertInt.nullable[name]; } else { assertion = assertInt.notNullable[name]; } } if (assertion === undefined) { throw new InternalError(`No integer assertion implemented for integer type ${name}`); } const inner = createSimpleValidator(schema, supervisor, assertion); // OMIT_FABRIC handling for the fabric-scoped struct FabricIndex sentinel (Matter §7.13.6). Mutation of // `siblings.fabricIndex` is an intentional bend of the read-only validator contract — covers both the // per-field StructManager setter path AND the ListManager.writeEntry path (which writes raw entry objects // bypassing per-field setters). One mechanism handles every setStateOf path. if (isFabricIndexSentinel(schema)) { const fieldName = schema.propertyName; const fieldId = schema.id; const fabricSentinelOrSubstitute: ValueSupervisor.Validate = (value, session, location) => { const peerContext = session.clientPeerContext; if (value !== FabricIndex.OMIT_FABRIC || peerContext === undefined) { inner(value, session, location); return; } const siblings = location.siblings as Val.Struct | undefined; if (siblings !== undefined && peerContext.fabricIndexOnPeer !== undefined) { // Substitute into the slot holding the sentinel — a name-keyed container may hold it under the // field's TLV tag number, and writing another slot would leave two disagreeing fabricIndex values siblings[memberSlotOf(siblings, fieldName, fieldId) ?? fieldName] = peerContext.fabricIndexOnPeer; return; } logger.debug( "fabricIndex was not replaced with the peer's fabric index because it is not yet known; local cache will hold the OMIT_FABRIC sentinel (-1) until the peer's subscription delivers the real value at", location.path, ); }; return fabricSentinelOrSubstitute; } return inner; } function createStructValidator(schema: Schema, supervisor: RootSupervisor): ValueSupervisor.Validate { const validators = {} as Record; for (const field of supervisor.membersOf(schema)) { // Skip deprecated, and global attributes we currently handle in lower levels if (AttributeModel.isGlobal(field) || (field.isDeprecated && !field.type)) { continue; } const validate = supervisor.get(field).validate; if (validate) { validators[field.propertyName] = { id: field.id, validate }; } } const validateStruct: ValueSupervisor.Validate = (struct, session, location) => { assertObject(struct, location); const config = location.config ?? maybeConfigOf(struct); const sublocation = { path: location.path.at(""), siblings: struct, choices: {}, outerResolve: location.outerResolve, config, owner: location.owner, } as ValidationLocation; for (const name in validators) { const { id, validate } = validators[name]; let value; // The validator has no reference and thus no primaryKey, so it accepts either spelling, name preferred: // a name-keyed struct decoded without a schema keys members by TLV tag number, and an id-keyed mirror // root keys by id canonically — do not narrow this to the read-fallback policy if ((struct as Val.Dynamic)[Val.properties]) { const properties = (struct as Val.Dynamic)[Val.properties](location.owner, session); if (name in properties) { value = properties[name]; } else { value = memberValueOf(struct, name, id); } } else { value = memberValueOf(struct, name, id); } sublocation.path.id = name; sublocation.config = config?.readonlyChild?.(name); validate(value, session, sublocation); } for (const name in sublocation.choices) { const choice = sublocation.choices[name]; if (choice.count < choice.target && !choice.orLess) { throw new ConformanceError( schema, location, `Too few fields present (${choice.count} of min ${choice.target})`, name, ); } if (choice.count > choice.target && !choice.orMore) { throw new ConformanceError( schema, location, `Too many fields present (${choice.count} of max ${choice.target})`, name, ); } } }; return validateStruct; } function createListValidator(schema: ValueModel, supervisor: RootSupervisor): ValueSupervisor.Validate | undefined { const entry = schema.listEntry; let validateEntries: undefined | ValueSupervisor.Validate; if (entry) { const entryValidator = supervisor.get(entry).validate; if (entryValidator) { validateEntries = (list: Val, session: ValueSupervisor.Session, location: ValidationLocation) => { if (!list || typeof (list as Iterable)[Symbol.iterator] !== "function") { throw new DatatypeError(location, "a list", list); } // Entry-level config: per-index overrides fall back to the "entry" key const entryConfig = location.config?.readonlyChild?.("entry"); let index = 0; const sublocation = { path: location.path.at(""), owner: location.owner, } as ValidationLocation; // An index names the position in the list, which an entry holding no value occupies too for (const e of list as Iterable) { if (e !== undefined && e !== null) { sublocation.path.id = index; sublocation.config = location.config?.readonlyChild?.(index) ?? entryConfig; entryValidator(e, session, sublocation); } index++; } }; } } const validateConstraint = createConstraintValidator(schema.effectiveConstraint, schema, supervisor); return (value, session, location) => { assertArray(value, location); validateConstraint?.(value, session, location); validateEntries?.(value, session, location); }; }