/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Bytes, ImplementationError, InternalError, Merge, UnexpectedDataError } from "@matter/general"; import { FabricIndex, FieldElement } from "@matter/model"; import { ValidationDatatypeMismatchError, ValidationError, ValidationMandatoryFieldMissingError, ValidationOutOfBoundsError, } from "../common/ValidationError.js"; import { TlvAny } from "./TlvAny.js"; import { LengthConstraints } from "./TlvArray.js"; import { TlvTag, TlvType, TlvTypeLength } from "./TlvCodec.js"; import { TlvEncodingOptions, TlvReader, TlvSchema, TlvWriter } from "./TlvSchema.js"; export interface FieldType { id: number; schema: TlvSchema; optional?: boolean; repeated?: boolean; fallback?: T; } export interface RepeatedFieldType extends FieldType { repeated: true; minLength?: number; maxLength?: number; } export interface OptionalFieldType extends FieldType { optional: true; } export interface OptionalRepeatedFieldType extends OptionalFieldType { repeated: true; maxLength?: number; } export type TlvFields = { [field: string]: FieldType }; type MandatoryFieldNames = { [K in keyof F]: F[K] extends OptionalFieldType ? never : K; }[keyof F]; type OptionalFieldNames = { [K in keyof F]: F[K] extends OptionalFieldType ? K : never; }[keyof F]; type TypeFromField> = F extends FieldType ? T : never; type TypeForMandatoryFields = { [K in MF]: TypeFromField }; type TypeForOptionalFields = { [K in MF]?: TypeFromField }; export type TypeFromFields = Merge< TypeForMandatoryFields>, TypeForOptionalFields> >; /** * Schema to encode an object in TLV. * * @see {@link MatterSpecification.v10.Core} § A.5.1 and § A.11.4 */ export class ObjectSchema extends TlvSchema> { readonly isFabricScoped: boolean; private readonly fieldById = new Array<{ name: string; field: FieldType }>(); constructor( private readonly fieldDefinitions: F, private readonly type: TlvType.Structure | TlvType.List = TlvType.Structure, private readonly allowProtocolSpecificTags = false, /** When true, list encoding follows caller property order instead of schema order. See {@link TlvTaggedListPreservingOrder}. */ private readonly preserveDataOrdering = false, ) { super(); let isFabricScoped = false; // TODO Add sorting option to enforce order of fields in encoded TLV If Ty is Structure // Requirements @see {@link MatterSpecification.Core.v12} § A.2.4 for (const name in this.fieldDefinitions) { const field = this.fieldDefinitions[name]; if (field.repeated && type !== TlvType.List) { throw new Error("Repeated fields are only allowed in TLV List."); } this.fieldById[field.id] = { name, field }; if (field.id === FabricIndex.id) { isFabricScoped = true; } } this.isFabricScoped = isFabricScoped; } /** @deprecated Part of old ClusterType() compat layer. */ override get element(): TlvSchema.Element { const children: FieldElement[] = []; for (const name in this.fieldDefinitions) { const field = this.fieldDefinitions[name]; const inner = field.schema.element; children.push( FieldElement({ name, id: field.id, ...inner, ...(field.optional && { conformance: "O" }), }), ); } return { type: "struct", children }; } #encodeEntryToTlv(writer: TlvWriter, name: string, value: TypeFromFields, options?: TlvEncodingOptions) { const { id, schema, optional: isOptional, repeated: isRepeated } = this.fieldDefinitions[name]; const { forWriteInteraction = false, allowMissingFieldsForNonFabricFilteredRead = false } = options ?? {}; if (forWriteInteraction && allowMissingFieldsForNonFabricFilteredRead) { throw new InternalError( "Encode options cannot indicate a write interaction and a fabric filtered read interaction at the same time.", ); } if (forWriteInteraction && id === FabricIndex.id) { // MatterSpecification §7.13.6: fabricIndex SHALL NOT be present in write interactions, regardless of // whether the caller provided a value. Server derives it from the accessing fabric. return; } const fieldValue = (value as any)[name]; if (fieldValue === undefined) { if (!isOptional && !allowMissingFieldsForNonFabricFilteredRead) { throw new ValidationMandatoryFieldMissingError(`Missing mandatory field ${name}`, name); } return; } if (isRepeated) { if (!Array.isArray(fieldValue)) { throw new ValidationDatatypeMismatchError(`Repeated field ${name} should be an array.`, name); } for (const element of fieldValue) { schema.encodeTlvInternal(writer, element, { id }, options); } } else { schema.encodeTlvInternal(writer, fieldValue, { id }, options); } } /** * Encode the object as Structure, by the order of field definitions. */ #encodeStructure(writer: TlvWriter, value: TypeFromFields, options?: TlvEncodingOptions) { for (const name in this.fieldDefinitions) { this.#encodeEntryToTlv(writer, name, value, options); } } /** * Encode the object as List. Default emits in schema definition order. With {@link preserveDataOrdering}, * caller-supplied property order is emitted first, with any remaining schema fields appended after. */ #encodeList(writer: TlvWriter, value: TypeFromFields, options?: TlvEncodingOptions) { const valueKeys = Object.keys(value); for (const name of valueKeys) { if (!Object.hasOwn(this.fieldDefinitions, name)) { throw new ValidationDatatypeMismatchError( `Unknown field "${name}" not defined in tagged list schema.`, name, ); } } if (this.preserveDataOrdering) { const encodedFields = new Set(); for (const name of valueKeys) { this.#encodeEntryToTlv(writer, name, value, options); encodedFields.add(name); } for (const name in this.fieldDefinitions) { if (encodedFields.has(name)) continue; this.#encodeEntryToTlv(writer, name, value, options); } return; } for (const name in this.fieldDefinitions) { this.#encodeEntryToTlv(writer, name, value, options); } } override encodeTlvInternal( writer: TlvWriter, value: TypeFromFields, tag?: TlvTag, options?: TlvEncodingOptions, ): void { writer.writeTag({ type: this.type }, tag); if (this.type === TlvType.Structure) { // Encode in order of field definitions this.#encodeStructure(writer, value, options); } else { this.#encodeList(writer, value, options); } writer.writeTag({ type: TlvType.EndOfContainer }); } override decodeTlvInternalValue(reader: TlvReader, typeLength: TlvTypeLength): TypeFromFields { if (typeLength.type !== this.type) throw new UnexpectedDataError(`Unexpected type ${typeLength.type} (expected ${this.type}).`); const result: any = {}; while (true) { const { tag: { profile, id } = {}, typeLength: elementTypeLength } = reader.readTagType(); if (elementTypeLength.type === TlvType.EndOfContainer) break; if (profile !== undefined && !this.allowProtocolSpecificTags) throw new UnexpectedDataError("Structure element tags should be context-specific."); if (id === undefined) throw new UnexpectedDataError("Structure element tags should have an id."); const fieldName = this.fieldById[id]; if (fieldName === undefined) { // Ignore unknown field by decoding it as raw TLV so we skip forward the proper length. TlvAny.decodeTlvInternalValue(reader, elementTypeLength); continue; } const { field, name } = fieldName; const decoded = field.schema.decodeTlvInternalValue(reader, elementTypeLength); if (field.repeated) { if (result[name] === undefined) { result[name] = [decoded]; } else { result[name].push(decoded); } } else { result[name] = decoded; } } // Check mandatory fields and, if missing, populate with fallback value if defined. for (const name in this.fieldDefinitions) { const { optional, fallback, repeated } = this.fieldDefinitions[name]; if (optional) continue; const value = result[name]; if (value !== undefined) continue; if (fallback !== undefined) { if (repeated) { result[name] = [fallback]; } else { result[name] = fallback; } } } return result as TypeFromFields; } override validate(value: TypeFromFields): void { for (const name in this.fieldDefinitions) { const { optional, schema, repeated: isRepeated } = this.fieldDefinitions[name]; const data = (value as any)[name]; if (data === undefined) { if (optional) { continue; } throw new ValidationMandatoryFieldMissingError(`Missing mandatory field ${name}`, name); } if (isRepeated) { const { minLength = 2, maxLength = 65535 } = this.fieldDefinitions[name] as RepeatedFieldType; if (!Array.isArray(data)) { throw new ValidationDatatypeMismatchError(`Repeated field ${name} should be an array.`, name); } if (data.length > maxLength) throw new ValidationOutOfBoundsError( `Repeated field list for ${name} is too long: ${data.length}, max ${maxLength}.`, name, ); if (data.length < minLength) throw new ValidationOutOfBoundsError( `Repeated field list for ${name} is too short: ${data.length}, min ${minLength}.`, name, ); for (const element of data) { try { schema.validate(element); } catch (e) { ValidationError.accept(e); e.fieldName = `${name}${e.fieldName !== undefined ? `.${e.fieldName}` : ""}`; throw e; } } } else { try { schema.validate(data); } catch (e) { ValidationError.accept(e); e.fieldName = `${name}${e.fieldName !== undefined ? `.${e.fieldName}` : ""}`; throw e; } } } } override injectField( value: TypeFromFields, fieldId: number, fieldValue: any, injectChecker: (fieldValue: any) => boolean, ): TypeFromFields { for (const k in this.fieldDefinitions) { const field = this.fieldDefinitions[k] as FieldType; if (field.id === fieldId) { if (injectChecker((value as any)[k])) { field.schema.validate(fieldValue); // Make sure type matches (value as any)[k] = fieldValue; } } else { (value as any)[k] = field.schema.injectField((value as any)[k], fieldId, fieldValue, injectChecker); } } return value; } override removeField( value: TypeFromFields, fieldId: number, removeChecker: (fieldValue: any) => boolean, ): TypeFromFields { for (const k in this.fieldDefinitions) { const field = this.fieldDefinitions[k] as FieldType; if (field.id === fieldId) { if ((value as any)[k] !== undefined && removeChecker((value as any)[k])) { delete (value as any)[k]; } } else { (value as any)[k] = field.schema.removeField((value as any)[k], fieldId, removeChecker); } } return value; } } /** Object TLV schema. */ export const TlvObject = (fields: F) => new ObjectSchema(fields, TlvType.Structure); export class ObjectSchemaWithMaxSize extends ObjectSchema { constructor( fieldDefinitions: F, protected readonly maxSize: number, type: TlvType.Structure | TlvType.List = TlvType.Structure, allowProtocolSpecificTags = false, ) { super(fieldDefinitions, type, allowProtocolSpecificTags); } override encode(value: TypeFromFields): Bytes { const encoded = super.encode(value); if (encoded.byteLength > this.maxSize) { throw new ImplementationError( `Encoded TLV object with ${encoded.byteLength} bytes exceeds maximum size of ${this.maxSize} bytes.`, ); } return encoded; } } export const TlvObjectWithMaxSize = (fields: F, maxSize: number) => new ObjectSchemaWithMaxSize(fields, maxSize, TlvType.Structure); /** * List TLV schema with all tagged entries. Members are emitted in schema definition order on encode. * List entries that can appear multiple times can be defined using TlvRepeatedField/TlvOptionalRepeatedField and are * represented as Arrays. * * For lists whose wire order must reproduce caller-supplied order, use {@link TlvTaggedListPreservingOrder}. * * TODO: We represent Tlv Lists right now as named object properties. This formally does not match the spec, which * defines a list as a sequence of TLV elements with optional tag where the order matters. That's ok for now * (also with the help of "Repeated Fields") because it not makes any real difference for now for the current * existing data structures. We need to change once this changes. */ export const TlvTaggedList = (fields: F, allowProtocolSpecificTags = false) => new ObjectSchema(fields, TlvType.List, allowProtocolSpecificTags); /** * Variant of {@link TlvTaggedList} that preserves caller-supplied member order on encode. Use only when wire-position * is application data that must round-trip bit-identically — e.g. operational-certificate subject/issuer/extensions * sub-lists, whose signed bytes depend on original member order. */ export const TlvTaggedListPreservingOrder = (fields: F, allowProtocolSpecificTags = false) => new ObjectSchema(fields, TlvType.List, allowProtocolSpecificTags, /* preserveDataOrdering */ true); // TODO Implement a real TlvList schema that matches the spec to represent a ordered list of TLV elements with optional // tag. /** * Object TLV mandatory field. Optionally provide a fallback value to initialize the field value when devices omit * providing a value against the specifications or in special use cases. Make sure to use a value that is an equivalent * to the value being empty. */ export const TlvField = (id: number, schema: TlvSchema, fallback?: T) => ({ id, schema, fallback, optional: false }) as FieldType; /** Object TLV optional field. */ export const TlvOptionalField = (id: number, schema: TlvSchema) => ({ id, schema, optional: true }) as OptionalFieldType; /** * Object TLV mandatory field that can exist repeated in a TLV List structure. The order is preserved on encoding and * decoding. */ export const TlvRepeatedField = (id: number, schema: TlvSchema, lengthOptions?: LengthConstraints) => { const { minLength, maxLength, length } = lengthOptions ?? {}; return { id, schema, optional: false, repeated: true, minLength: length ?? minLength, maxLength: length ?? maxLength, } as RepeatedFieldType; }; /** * Object TLV optional field that can exist repeated in a TLV List structure. The order is preserved on encoding and * decoding. */ export const TlvOptionalRepeatedField = ( id: number, schema: TlvSchema, lengthOptions?: { maxLength: number }, ) => { const { maxLength } = lengthOptions ?? {}; return { id, schema, optional: true, repeated: true, minLength: 0, maxLength, } as OptionalRepeatedFieldType; };