diff --git a/examples/commons/operators.cddl b/examples/commons/operators.cddl index e54df91..3732f65 100644 --- a/examples/commons/operators.cddl +++ b/examples/commons/operators.cddl @@ -3,6 +3,12 @@ label = bstr .size (1..63) nai = tstr .regexp "[A-Za-z0-9]+@[A-Za-z0-9]+(\\.[A-Za-z0-9]+)+" rwxbits = uint .bits rwx +coseHeader = { alg: int } +cosePayload = bstr .cbor coseHeader + +coseHeaders = [coseHeader] +coseSeq = bstr .cborseq coseHeaders + foo = ip4 .and nai fooWithin = ip4 .within nai bar = foo .ne fooWithin diff --git a/packages/cddl/docs/operators.md b/packages/cddl/docs/operators.md index 9a900c6..e29508a 100644 --- a/packages/cddl/docs/operators.md +++ b/packages/cddl/docs/operators.md @@ -7,7 +7,7 @@ Operators in CDDL modify or constrain types, providing additional semantics beyo In the AST, an operator is represented by the following structure: ```typescript -export type OperatorType = 'default' | 'size' | 'regexp' | 'bits' | 'and' | 'within' | 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge' +export type OperatorType = 'default' | 'size' | 'regexp' | 'bits' | 'and' | 'within' | 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge' | 'cbor' | 'cborseq' export interface Operator { Type: OperatorType @@ -35,6 +35,8 @@ CDDL supports the following operators, represented in the AST: 10. `le`: Less than or equal comparison 11. `gt`: Greater than comparison 12. `ge`: Greater than or equal comparison +13. `cbor`: Asserts a byte string, decoded as CBOR, matches the given type (RFC 8610 §3.8.4) +14. `cborseq`: Asserts a byte string, decoded as a sequence of CBOR items, matches the given (array) type (RFC 8610 §3.8.4) ## Operator Location in the AST @@ -242,6 +244,51 @@ AST representation: } ``` +### CBOR Operators + +The `.cbor` operator asserts that a byte string, once decoded as CBOR, matches +the given type - the standard way to describe a byte string containing nested +CBOR-encoded data, e.g. how COSE (RFC 9052) describes a `COSE_Sign1`'s +protected header or payload field. `.cborseq` is the same idea for a byte +string containing a *sequence* of CBOR items, matched against an array type +(RFC 8610 §3.8.4): + +```cddl +coseHeader = { alg: int } +cosePayload = bstr .cbor coseHeader +``` + +AST representation: + +```json +{ + "Type": "variable", + "Name": "cosePayload", + "IsChoiceAddition": false, + "PropertyType": [ + { + "Type": "bstr", + "Operator": { + "Type": "cbor", + "Value": { + "Type": "group", + "Value": "coseHeader", + "Unwrapped": false + } + } + } + ], + "Comments": [] +} +``` + +`.cborseq` produces the identical shape with `"Type": "cborseq"` in place of +`"cbor"`. Like `.size`/`.regexp`/the comparison operators, this is a +validation constraint on the value, not a structural type change - the +`cddl2*` generators all resolve `bstr .cbor X` to their native byte-array +type (`Uint8Array`, `bytes`, `[UInt8]`, `ByteArray`, `byte[]`), ignoring the +operator and its referenced type. + ### Comparison Operators The `.ge`, `.gt`, `.le`, `.lt`, `.eq`, and `.ne` operators specify numerical constraints: diff --git a/packages/cddl/src/ast.ts b/packages/cddl/src/ast.ts index 7794b03..6fca0ed 100644 --- a/packages/cddl/src/ast.ts +++ b/packages/cddl/src/ast.ts @@ -156,7 +156,7 @@ export type Range = { Inclusive: boolean } -export type OperatorType = 'default' | 'size' | 'regexp' | 'bits' | 'and' | 'within' | 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge' +export type OperatorType = 'default' | 'size' | 'regexp' | 'bits' | 'and' | 'within' | 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge' | 'cbor' | 'cborseq' export interface Operator { Type: OperatorType Value: PropertyType diff --git a/packages/cddl/src/parser.ts b/packages/cddl/src/parser.ts index d851979..c7362c2 100644 --- a/packages/cddl/src/parser.ts +++ b/packages/cddl/src/parser.ts @@ -13,7 +13,7 @@ import { const NIL_TOKEN: Token = { Type: Tokens.ILLEGAL, Literal: '' } const DEFAULT_OCCURRENCE: Occurrence = { n: 1, m: 1 } // exactly one time -const OPERATORS: OperatorType[] = ['default', 'size', 'regexp', 'bits', 'and', 'within', 'eq', 'ne', 'lt', 'le', 'gt', 'ge'] +const OPERATORS: OperatorType[] = ['default', 'size', 'regexp', 'bits', 'and', 'within', 'eq', 'ne', 'lt', 'le', 'gt', 'ge', 'cbor', 'cborseq'] const OPERATORS_EXPECTING_VALUES: Record = { default: undefined, size: ['literal', 'range'], @@ -27,6 +27,8 @@ const OPERATORS_EXPECTING_VALUES: Record can parse operators 1`] = ` ], "Type": "variable", }, + { + "Comments": [], + "IsChoiceAddition": false, + "Name": "coseHeader", + "Properties": [ + { + "Comments": [], + "HasCut": true, + "Name": "alg", + "Occurrence": { + "m": 1, + "n": 1, + }, + "Type": [ + "int", + ], + }, + ], + "Type": "group", + }, + { + "Comments": [], + "IsChoiceAddition": false, + "Name": "cosePayload", + "PropertyType": [ + { + "Operator": { + "Type": "cbor", + "Value": { + "Type": "group", + "Unwrapped": false, + "Value": "coseHeader", + }, + }, + "Type": "bstr", + }, + ], + "Type": "variable", + }, + { + "Comments": [], + "Name": "coseHeaders", + "Type": "array", + "Values": [ + { + "Comments": [], + "HasCut": false, + "Name": "", + "Occurrence": { + "m": 1, + "n": 1, + }, + "Type": [ + { + "Type": "group", + "Unwrapped": false, + "Value": "coseHeader", + }, + ], + }, + ], + }, + { + "Comments": [], + "IsChoiceAddition": false, + "Name": "coseSeq", + "PropertyType": [ + { + "Operator": { + "Type": "cborseq", + "Value": { + "Type": "group", + "Unwrapped": false, + "Value": "coseHeaders", + }, + }, + "Type": "bstr", + }, + ], + "Type": "variable", + }, { "Comments": [], "IsChoiceAddition": false, diff --git a/packages/cddl/tests/parser.test.ts b/packages/cddl/tests/parser.test.ts index 228055a..447c9bd 100644 --- a/packages/cddl/tests/parser.test.ts +++ b/packages/cddl/tests/parser.test.ts @@ -269,4 +269,51 @@ describe('parser', () => { vi.restoreAllMocks() }) + + it('parses the .cbor operator on a map member, referencing another rule as the embedded type', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValue('inner = { a: int }\nouter = { ? parent: bstr .cbor inner }\n') + const p = new Parser('foo.cddl') + + const [, outer] = p.parse() as Group[] + expect(outer.Properties).toEqual([{ + HasCut: true, + Occurrence: { n: 0, m: 1 }, + Name: 'parent', + Type: [{ + Type: 'bstr', + Operator: { + Type: 'cbor', + Value: { Type: 'group', Value: 'inner', Unwrapped: false } + } + }], + Comments: [] + }]) + + vi.restoreAllMocks() + }) + + it('parses the .cborseq operator the same way, on a required member', () => { + // .cborseq's argument must be an array type (RFC 8610 §3.8.4: the decoded + // sequence, taken as an array, is matched against it) - unlike .cbor's inner, + // which matches a single decoded item and can legitimately be a map. + vi.spyOn(fs, 'readFileSync').mockReturnValue('innerList = [int]\nouter = { payload: bstr .cborseq innerList }\n') + const p = new Parser('foo.cddl') + + const [, outer] = p.parse() as Group[] + expect(outer.Properties).toEqual([{ + HasCut: true, + Occurrence: { n: 1, m: 1 }, + Name: 'payload', + Type: [{ + Type: 'bstr', + Operator: { + Type: 'cborseq', + Value: { Type: 'group', Value: 'innerList', Unwrapped: false } + } + }], + Comments: [] + }]) + + vi.restoreAllMocks() + }) }) diff --git a/packages/cddl2java/src/index.ts b/packages/cddl2java/src/index.ts index 411e64a..fe44d7e 100644 --- a/packages/cddl2java/src/index.ts +++ b/packages/cddl2java/src/index.ts @@ -477,6 +477,8 @@ export function parseType (specType: any): { type: string, isLiteral: boolean } return { type: 'Float', isLiteral: false }; } else if ((specType[0] === 'string' || specType[0] === 'text')) { return { type: 'String', isLiteral: false }; + } else if (specType[0] === 'bstr' || specType[0] === 'bytes') { + return { type: 'byte[]', isLiteral: false }; } else if (specType[0] === 'null' || specType[0] === 'any') { return { type: 'Object', isLiteral: false }; // null/any represented as Object in Java } else if (typeof specType[0] === 'object') { @@ -571,6 +573,15 @@ export function parseType (specType: any): { type: string, isLiteral: boolean } } } } + + // Any other operator (.cbor, .cborseq, .size, .regexp, .bits, .and, .within, + // .eq, .ne, .lt, .le, .gt, .ge, ...) is a validation constraint on the value, + // not a structural type change - resolve the underlying type and ignore it, + // the same way the other generators already treat these operators. + if (typeof specType[0].Type === 'string') { + return parseType([specType[0].Type]); + } + /* c8 ignore next */ throw new Error(`Unknown operator: ${JSON.stringify(specType[0].Operator)}`) } } diff --git a/packages/cddl2java/tests/parseType.test.ts b/packages/cddl2java/tests/parseType.test.ts index 93b4f21..e98f15c 100644 --- a/packages/cddl2java/tests/parseType.test.ts +++ b/packages/cddl2java/tests/parseType.test.ts @@ -84,4 +84,38 @@ describe('parseType', () => { expect(parseType(specType)).toEqual({ type: 'Integer', isLiteral: false }) }) }) + + describe('byte strings', () => { + it('resolves bare bstr/bytes to byte[]', () => { + // was "Unknown" before the fix - bstr/bytes had no primitive-type mapping at all + expect(parseType(['bstr'])).toEqual({ type: 'byte[]', isLiteral: false }) + expect(parseType(['bytes'])).toEqual({ type: 'byte[]', isLiteral: false }) + }) + + it('resolves a bstr with an unrecognized operator by ignoring the operator, not throwing', () => { + // .cbor/.cborseq (RFC 8610 §3.8.4) are validation constraints on a byte string, + // not a structural type change - same as every other generator already treats + // .size/.and/.within/etc. Before the fix this threw "Unknown operator". The + // generator never looks at what the reference resolves to, but the names below + // still follow the RFC: .cbor's argument matches a single decoded item (a map + // is fine), .cborseq's matches the sequence taken as an array. + const cborSpecType = [{ + Type: 'bstr', + Operator: { + Type: 'cbor', + Value: { Type: 'group', Value: 'inner', Unwrapped: false } + } + }] + expect(parseType(cborSpecType)).toEqual({ type: 'byte[]', isLiteral: false }) + + const cborseqSpecType = [{ + Type: 'bstr', + Operator: { + Type: 'cborseq', + Value: { Type: 'group', Value: 'innerList', Unwrapped: false } + } + }] + expect(parseType(cborseqSpecType)).toEqual({ type: 'byte[]', isLiteral: false }) + }) + }) }) diff --git a/packages/cddl2kotlin/tests/transform.test.ts b/packages/cddl2kotlin/tests/transform.test.ts index bfbdeda..70ba6fb 100644 --- a/packages/cddl2kotlin/tests/transform.test.ts +++ b/packages/cddl2kotlin/tests/transform.test.ts @@ -22,6 +22,30 @@ describe('transform', () => { expect(output).toContain('typealias DeviceAddress = String') }) + it.each([ + ['cbor', 'inner'], // .cbor's argument matches a single decoded item - a map is fine + ['cborseq', 'innerList'] // .cborseq's argument matches the sequence taken as an array (RFC 8610 §3.8.4) + ] as const)('should ignore the .%s operator and still map bstr to ByteArray', (operatorType, referencedType) => { + // Either way this is a validation constraint on a byte string, not a structural + // type change - same as every other operator (.size, .and, ...); the generator + // never looks at what the reference resolves to. + const assignment: Variable = { + Type: 'variable', + Name: 'payload', + PropertyType: { + Type: 'bstr', + Operator: { + Type: operatorType, + Value: { Type: 'group', Value: referencedType, Unwrapped: false } + } + } as any, + Comments: [], + IsChoiceAddition: false + } + const output = transform([assignment]) + expect(output).toContain('typealias Payload = ByteArray') + }) + it('should transform a string literal union into an enum class', () => { const assignment: Variable = { Type: 'variable', diff --git a/packages/cddl2py/tests/transform_edge_cases.test.ts b/packages/cddl2py/tests/transform_edge_cases.test.ts index 47065e2..bea6e68 100644 --- a/packages/cddl2py/tests/transform_edge_cases.test.ts +++ b/packages/cddl2py/tests/transform_edge_cases.test.ts @@ -656,4 +656,21 @@ describe('transform edge cases', () => { expect(output).toContain('# leading docs') expect(output).toContain('Commented = str') }) + + it.each([ + ['cbor', 'inner'], // .cbor's argument matches a single decoded item - a map is fine + ['cborseq', 'innerList'] // .cborseq's argument matches the sequence taken as an array (RFC 8610 §3.8.4) + ] as const)('should ignore the .%s operator and still map bstr to bytes', (operatorType, referencedType) => { + // Either way this is a validation constraint on a byte string, not a structural + // type change - same as every other operator (.size, .and, ...); the generator + // never looks at what the reference resolves to. + const output = transform([variable('payload', { + Type: 'bstr', + Operator: { + Type: operatorType, + Value: { Type: 'group', Value: referencedType, Unwrapped: false } + } + } as any)]) + expect(output).toContain('Payload = bytes') + }) }) diff --git a/packages/cddl2swift/tests/transform.test.ts b/packages/cddl2swift/tests/transform.test.ts index 5f77093..60c4480 100644 --- a/packages/cddl2swift/tests/transform.test.ts +++ b/packages/cddl2swift/tests/transform.test.ts @@ -22,6 +22,30 @@ describe('transform', () => { expect(output).toContain('public typealias DeviceAddress = String') }) + it.each([ + ['cbor', 'inner'], // .cbor's argument matches a single decoded item - a map is fine + ['cborseq', 'innerList'] // .cborseq's argument matches the sequence taken as an array (RFC 8610 §3.8.4) + ] as const)('should ignore the .%s operator and still map bstr to [UInt8]', (operatorType, referencedType) => { + // Either way this is a validation constraint on a byte string, not a structural + // type change - same as every other operator (.size, .and, ...); the generator + // never looks at what the reference resolves to. + const assignment: Variable = { + Type: 'variable', + Name: 'payload', + PropertyType: { + Type: 'bstr', + Operator: { + Type: operatorType, + Value: { Type: 'group', Value: referencedType, Unwrapped: false } + } + } as any, + Comments: [], + IsChoiceAddition: false + } + const output = transform([assignment]) + expect(output).toContain('public typealias Payload = [UInt8]') + }) + it('should transform a string literal union into a raw value enum', () => { const assignment: Variable = { Type: 'variable', diff --git a/packages/cddl2ts/tests/transform.test.ts b/packages/cddl2ts/tests/transform.test.ts index 0084d2d..c6efdb2 100644 --- a/packages/cddl2ts/tests/transform.test.ts +++ b/packages/cddl2ts/tests/transform.test.ts @@ -66,6 +66,23 @@ describe('literal transformation direct', () => { expect(output).toContain(`export type ${name.split('-').map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`).join('')} = ${expectedType};`) }) + it.each([ + ['cbor', 'inner'], // .cbor's argument matches a single decoded item - a map is fine + ['cborseq', 'innerList'] // .cborseq's argument matches the sequence taken as an array (RFC 8610 §3.8.4) + ] as const)('should ignore the .%s operator and still map bstr to Uint8Array', (operatorType, referencedType) => { + // Either way this is a validation constraint on a byte string, not a structural + // type change - same as every other operator (.size, .and, ...); the generator + // never looks at what the reference resolves to. + const output = transform([variable('payload', { + Type: 'bstr', + Operator: { + Type: operatorType, + Value: { Type: 'group', Value: referencedType, Unwrapped: false } + } + } as any)]) + expect(output).toContain('export type Payload = Uint8Array;') + }) + it('should keep bytes fields as object properties instead of record aliases', () => { const output = transform([ group('network-get-data-result', [