diff --git a/README.md b/README.md index 66bc9ab..aeb21a9 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ npm install protodef-validator See [example](example.js) +Validation also checks literal switch cases against the output values of a known +mapper. A default does not permit an impossible explicit case; unused mapper +values are allowed. Errors identify the definition, comparator and invalid case. + +This check follows ordinary container fields, parent references and unambiguous +non-parameterized protocol aliases. It skips relationships whose meaning is +unknown, including custom types, dynamic case keys and ambiguous scopes. +`addType` schemas alone do not describe decoded values. This checks mapper/switch +consistency, not whether mapper wire IDs match an external protocol. + ## Command Line Interface You can install this package globally with `npm install -g protodef-validator` and then run `protodef-validator someProtocol.json` to validate it. diff --git a/index.js b/index.js index dbd1c19..99ee794 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,19 @@ const Ajv = require('ajv'); const assert=require("assert"); +const mappings = require('./validate-mappings'); + +function validateTypeSchema(type) { + this.rebuildDataType(); + let valid = this.ajv.validate("dataType",type); + this.compiled=true; + if(!valid) { + console.log(JSON.stringify(this.ajv.errors[0],null,2)); + if(this.ajv.errors[0]['parentSchema']['title']=="dataType") { + this.validateTypeGoingInside(this.ajv.errors[0]['data']); + } + throw new Error("validation error"); + } +} class Validator { constructor(typesSchemas) { @@ -89,16 +103,8 @@ class Validator { } validateType(type) { - this.rebuildDataType(); - let valid = this.ajv.validate("dataType",type); - this.compiled=true; - if(!valid) { - console.log(JSON.stringify(this.ajv.errors[0],null,2)); - if(this.ajv.errors[0]['parentSchema']['title']=="dataType") { - this.validateTypeGoingInside(this.ajv.errors[0]['data']); - } - throw new Error("validation error"); - } + validateTypeSchema.call(this,type); + mappings.validateType(type,this.typesSchemas); } validateTypeGoingInside(type) { @@ -138,7 +144,7 @@ class Validator { Object.keys(p[k]).forEach(typeName => v.addType(typeName)); Object.keys(p[k]).forEach(typeName => { try { - v.validateType(p[k][typeName], path + "." + k + "." + typeName); + validateTypeSchema.call(v,p[k][typeName]); } catch(e) { throw new Error("Error at "+path + "." + k + "." + typeName); @@ -151,6 +157,7 @@ class Validator { }) } validateTypes(protocol,this,"root"); + mappings.validateProtocol(protocol,this.typesSchemas); } } diff --git a/package.json b/package.json index 20ac0f2..bfc6de8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Validate ProtoDef protocol definition in node", "main": "index.js", "scripts": { - "test": "node example.js" + "test": "node example.js && node test.js" }, "repository": { "type": "git", diff --git a/test.js b/test.js new file mode 100644 index 0000000..d920bef --- /dev/null +++ b/test.js @@ -0,0 +1,290 @@ +const assert = require('assert'); +const Validator = require('./'); + +let tests = 0; +function test(name, run) { + try { + run(); + tests++; + } catch (error) { + error.message = name + ': ' + error.message; + throw error; + } +} + +function mapper(mappings = { 0: 'alpha', 1: 'beta' }) { + return ['mapper', { type: 'u8', mappings }]; +} + +function field(name, type) { + return { name, type }; +} + +function container(...fields) { + return ['container', fields]; +} + +function choice(fields, compareTo = 'kind', defaultType) { + const options = { compareTo, fields }; + if (defaultType !== undefined) options.default = defaultType; + return ['switch', options]; +} + +function packet(payload, kind = mapper()) { + return container(field('kind', kind), field('payload', payload)); +} + +function rejects(run, location, comparator, invalidCase) { + assert.throws(run, error => { + assert.ok(error.message.includes(location), error.message); + assert.ok(error.message.includes(JSON.stringify(comparator)), error.message); + assert.ok(error.message.includes(JSON.stringify(invalidCase)), error.message); + assert.match(error.message, /mapper/i); + return true; + }); +} + +test('valid cases preserve the success return value', () => { + assert.strictEqual(new Validator().validateType(packet(choice({ alpha: 'u8', beta: 'void' }))), undefined); +}); + +test('impossible literal case includes its field location', () => { + rejects(() => new Validator().validateType(packet(choice({ gamma: 'void' }))), 'payload', 'kind', 'gamma'); +}); + +test('wire keys are not mapper outputs', () => { + rejects(() => new Validator().validateType(packet(choice({ 0: 'void' }))), 'payload', 'kind', '0'); +}); + +test('a default does not permit an impossible explicit case', () => { + rejects(() => new Validator().validateType(packet(choice({ gamma: 'void' }, 'kind', 'void'))), 'payload', 'kind', 'gamma'); +}); + +test('partial and empty case coverage are allowed', () => { + const validator = new Validator(); + validator.validateType(packet(choice({ alpha: 'void' }))); + validator.validateType(packet(choice({ alpha: 'void' }, 'kind', 'u8'))); + validator.validateType(packet(choice({}, 'kind', 'void'))); +}); + +test('numeric-looking output strings remain valid cases', () => { + new Validator().validateType(packet(choice({ 7: 'void' }), mapper({ 0: '7' }))); +}); + +test('named mapper aliases and chains are resolved', () => { + rejects(() => new Validator().validateProtocol({ types: { + Kind: mapper(), Alias: 'Kind', Packet: packet(choice({ gamma: 'void' }), 'Alias') + } }), 'root.types.Packet', 'kind', 'gamma'); +}); + +test('named container aliases preserve parent references at their use site', () => { + rejects(() => new Validator().validateProtocol({ types: { + Body: container(field('payload', choice({ gamma: 'void' }, '../kind'))), + Packet: packet('Body') + } }), 'Packet', '../kind', 'gamma'); +}); + +test('preceding named-container paths resolve their child fields', () => { + rejects(() => new Validator().validateType(container( + field('header', container(field('kind', mapper()))), + field('payload', choice({ gamma: 'void' }, 'header/kind')) + )), 'payload', 'header/kind', 'gamma'); +}); + +test('parent references cross ordinary containers', () => { + rejects(() => new Validator().validateType(packet(container( + field('payload', choice({ gamma: 'void' }, '../kind'))) + )), 'payload', '../kind', 'gamma'); +}); + +test('multiple parent references retain the correct frame', () => { + rejects(() => new Validator().validateType(packet(container( + field('inner', container(field('payload', choice({ gamma: 'void' }, '../../kind'))))) + )), 'payload', '../../kind', 'gamma'); +}); + +test('bare names do not implicitly search parent containers', () => { + new Validator().validateType(packet(container(field('payload', choice({ gamma: 'void' }))))); +}); + +test('a local mapper shadows the parent field', () => { + new Validator().validateType(packet(packet(choice({ gamma: 'void' }), mapper({ 0: 'gamma' })))); +}); + +test('a local scalar shadows the parent mapper', () => { + new Validator().validateType(packet(packet(choice({ gamma: 'void' }), 'u8'))); +}); + +test('a later non-mapper field invalidates an earlier same-name mapper', () => { + new Validator().validateType(container(field('kind', mapper()), field('kind', 'u8'), field('payload', choice({ gamma: 'void' })))); +}); + +test('future fields and sibling fields are not visible', () => { + new Validator().validateType(container(field('payload', choice({ gamma: 'void' })), field('kind', mapper()))); + new Validator().validateType(container( + field('left', container(field('kind', mapper()))), + field('right', container(field('payload', choice({ gamma: 'void' })))) + )); +}); + +test('array and option wrappers do not add container scope', () => { + for (const wrap of [type => ['array', { count: 1, type }], type => ['option', type]]) { + rejects(() => new Validator().validateType(packet(wrap(choice({ gamma: 'void' })))), 'payload', 'kind', 'gamma'); + rejects(() => new Validator().validateType(packet(wrap(container(field('payload', choice({ gamma: 'void' }, '../kind')))))), 'payload', '../kind', 'gamma'); + } +}); + +test('switch branches and defaults retain their container scope', () => { + rejects(() => new Validator().validateType(packet(choice({ alpha: choice({ gamma: 'void' }) }))), 'payload', 'kind', 'gamma'); + rejects(() => new Validator().validateType(packet(choice({}, 'kind', choice({ gamma: 'void' })))), 'payload', 'kind', 'gamma'); +}); + +test('containers inside switch branches create only their own scope', () => { + rejects(() => new Validator().validateType(packet(choice({ alpha: + container(field('payload', choice({ gamma: 'void' }, '../kind'))) + }))), 'payload', '../kind', 'gamma'); +}); + +test('native declarations of standard built-ins retain their meaning', () => { + rejects(() => new Validator().validateProtocol({ types: { + mapper: 'native', switch: 'native', container: 'native', u8: 'native', void: 'native', + Packet: packet(choice({ gamma: 'void' })) + } }), 'root.types.Packet', 'kind', 'gamma'); +}); + +test('repeated standard native declarations are not ambiguous', () => { + rejects(() => new Validator().validateProtocol({ types: { mapper: 'native' }, play: { types: { + mapper: 'native', Packet: packet(choice({ gamma: 'void' })) + } } }), 'play.types.Packet', 'kind', 'gamma'); +}); + +test('protocol overrides of a built-in remain opaque', () => { + new Validator().validateProtocol({ types: { + mapper: container(field('value', 'u8')), Packet: packet(choice({ gamma: 'void' })) + } }); +}); + +test('custom schemas overriding built-ins remain opaque', () => { + const validator = new Validator({ mapper: { + type: 'array', items: [{ enum: ['mapper'] }, { type: 'object' }], additionalItems: false + } }); + validator.validateType(packet(choice({ gamma: 'void' }))); +}); + +test('custom native fields have unknown output domains', () => { + new Validator().validateProtocol({ types: { Kind: 'native', Packet: packet(choice({ gamma: 'void' }), 'Kind') } }); +}); + +test('addType schemas do not supply alias definitions or output domains', () => { + const validator = new Validator(); + validator.addType('Kind', { enum: ['Kind'] }); + validator.validateType(packet(choice({ gamma: 'void' }), 'Kind')); +}); + +test('parameterized aliases are not expanded as fixed definitions', () => { + new Validator().validateProtocol({ types: { + Kind: mapper(), Packet: packet(choice({ gamma: 'void' }), ['Kind', { ignored: true }]) + } }); +}); + +test('unresolved mapper parameters do not imply literal output values', () => { + new Validator().validateProtocol({ types: { + Kind: mapper({ 0: '$label' }), Packet: packet(choice({ gamma: 'void' }), 'Kind') + } }); +}); + +test('conflicting aliases across namespaces are ambiguous', () => { + new Validator().validateProtocol({ types: { Kind: mapper() }, play: { types: { + Kind: mapper({ 0: 'gamma' }), Packet: packet(choice({ delta: 'void' }), 'Kind') + } } }); +}); + +test('independent namespaces do not share alias definitions', () => { + new Validator().validateProtocol({ + first: { types: { Kind: mapper(), Packet: packet(choice({ alpha: 'void' }), 'Kind') } }, + second: { types: { Kind: mapper({ 0: 'gamma' }), Packet: packet(choice({ gamma: 'void' }), 'Kind') } } + }); + rejects(() => new Validator().validateProtocol({ + first: { types: { Kind: mapper() } }, + second: { types: { Kind: mapper({ 0: 'gamma' }), Packet: packet(choice({ alpha: 'void' }), 'Kind') } } + }), 'second.types.Packet', 'kind', 'alpha'); +}); + +test('recursive aliases terminate without inventing a domain', () => { + new Validator().validateProtocol({ types: { + First: 'Second', Second: 'First', Packet: packet(choice({ gamma: 'void' }), 'First') + } }); +}); + +test('recursive container aliases still check non-recursive local relationships', () => { + rejects(() => new Validator().validateProtocol({ types: { + Node: container(field('next', ['option', 'Node']), field('kind', mapper()), field('payload', choice({ gamma: 'void' }))) + } }), 'root.types.Node', 'kind', 'gamma'); +}); + +test('dynamic switch keys are skipped without hiding invalid literals', () => { + new Validator().validateType(packet(choice({ '/selected': 'void', alpha: 'void' }))); + rejects(() => new Validator().validateType(packet(choice({ '/selected': 'void', gamma: 'void' }))), 'payload', 'kind', 'gamma'); +}); + +test('compareToValue and absolute-root comparisons are not inferred', () => { + new Validator().validateType(packet(['switch', { compareToValue: 'kind', fields: { gamma: 'void' } }])); + new Validator().validateType(packet(choice({ gamma: 'void' }, '/kind'))); +}); + +test('array-index projections are not treated as container paths', () => { + new Validator().validateType(container( + field('entries', ['array', { count: 1, type: container(field('kind', mapper())) }]), + field('payload', choice({ gamma: 'void' }, 'entries/0/kind')) + )); +}); + +test('opaque custom payloads are not recursively interpreted', () => { + const validator = new Validator(); + validator.addType('wrapper'); + validator.validateType(['wrapper', { type: packet(choice({ gamma: 'void' })) }]); +}); + +test('unrelated named custom fields do not erase known mapper fields', () => { + const validator = new Validator(); + validator.addType('custom'); + rejects(() => validator.validateType(container( + field('kind', mapper()), field('other', 'custom'), field('payload', choice({ gamma: 'void' })) + )), 'payload', 'kind', 'gamma'); +}); + +test('named custom fields replace same-name assumptions', () => { + const validator = new Validator(); + validator.addType('custom'); + validator.validateType(container(field('kind', mapper()), field('kind', 'custom'), field('payload', choice({ gamma: 'void' })))); +}); + +test('anonymous custom output invalidates surrounding field assumptions', () => { + const validator = new Validator(); + validator.addType('custom'); + validator.validateType(container(field('kind', mapper()), { anon: true, type: 'custom' }, field('payload', choice({ gamma: 'void' })))); + validator.validateType(packet(container({ anon: true, type: 'custom' }, field('payload', choice({ gamma: 'void' }, '../kind'))))); +}); + +test('self-contained relationships inside anonymous containers are checked', () => { + rejects(() => new Validator().validateType(container({ anon: true, type: packet(choice({ gamma: 'void' })) })), 'payload', 'kind', 'gamma'); +}); + +test('validation does not mutate input or retain domains between calls', () => { + const validator = new Validator(); + const protocol = { types: { Kind: mapper(), Packet: packet(choice({ alpha: 'void' }), 'Kind') } }; + const before = JSON.stringify(protocol); + validator.validateProtocol(protocol); + validator.validateProtocol(protocol); + assert.strictEqual(JSON.stringify(protocol), before); + rejects(() => validator.validateType(packet(choice({ gamma: 'void' }))), 'payload', 'kind', 'gamma'); + validator.validateType(packet(choice({ gamma: 'void' }), 'u8')); + validator.validateProtocol({ types: { Kind: mapper({ 0: 'gamma' }), Packet: packet(choice({ gamma: 'void' }), 'Kind') } }); +}); + +test('malformed schemas are still rejected', () => { + assert.throws(() => new Validator().validateType(['switch', { compareTo: 'kind' }])); + assert.throws(() => new Validator().validateProtocol({ types: { Broken: ['mapper', { type: 'u8' }] } })); +}); + +console.log(tests + ' validation tests passed'); diff --git a/validate-mappings.js b/validate-mappings.js new file mode 100644 index 0000000..3694bd6 --- /dev/null +++ b/validate-mappings.js @@ -0,0 +1,157 @@ +const { isDeepStrictEqual } = require('util'); + +const builtinSchemas = Object.assign({}, + require('./ProtoDef/schemas/numeric.json'), + require('./ProtoDef/schemas/utils.json'), + require('./ProtoDef/schemas/structures.json'), + require('./ProtoDef/schemas/conditional.json'), + require('./ProtoDef/schemas/primitives.json')); +const unknown = { kind: 'unknown' }; + +function hasParameters(value) { + if(typeof value === 'string') return value.startsWith('$'); + if(value && typeof value === 'object') + return Object.keys(value).some(key => hasParameters(value[key])); + return false; +} + +function createChecker(schemas) { + const builtins = new Set(Object.keys(builtinSchemas).filter(name => + isDeepStrictEqual(schemas[name], builtinSchemas[name]))); + + function resolve(type, definitions, active) { + const name = Array.isArray(type) ? type[0] : type; + const args = Array.isArray(type) ? type[1] : undefined; + if(typeof name !== 'string' || name.startsWith('$')) return null; + const definition = definitions.get(name); + if(Object.prototype.hasOwnProperty.call(builtinSchemas, name)) { + if(!builtins.has(name) || (definitions.has(name) && definition !== 'native')) + return null; + return { name, args, active }; + } + // A schema describes the shape of a type declaration, not its decoded values. + if(!definition || definition === 'native' || args !== undefined || active.has(name)) + return null; + if(hasParameters(definition)) return null; + const next = new Set(active); + next.add(name); + return resolve(definition, definitions, next); + } + + function fieldAt(compareTo, scope) { + if(typeof compareTo !== 'string' || compareTo.startsWith('/') || compareTo.startsWith('$')) + return unknown; + const parts = compareTo.split('/'); + let current = scope; + while(parts[0] === '..') { + if(!current) return unknown; + current = current.parent; + parts.shift(); + } + if(!current || !parts.length || parts.some(part => !part || part === '..' || /^\d+$/.test(part))) + return unknown; + let value = current.fields.get(parts.shift()) || unknown; + for(const part of parts) { + if(value.kind !== 'container') return unknown; + value = value.fields.get(part) || unknown; + } + return value; + } + + function checkSwitch(args, scope, path) { + if(args.compareToValue !== undefined) return; + const producer = fieldAt(args.compareTo, scope); + if(producer.kind !== 'mapper') return; + for(const value of Object.keys(args.fields)) { + // These keys are runtime references, rather than literal case values. + if(value.startsWith('/')) continue; + if(!producer.values.has(value)) { + throw new Error('Error at ' + path + ': switch compareTo ' + + JSON.stringify(args.compareTo) + ' has impossible case ' + JSON.stringify(value) + + ' (not a mapper output)'); + } + } + } + + function walk(type, scope, definitions, path, active) { + const resolved = resolve(type, definitions, active); + if(!resolved) return unknown; + const { name, args } = resolved; + active = resolved.active; + if(name === 'mapper') { + if(!args || hasParameters(args)) return unknown; + return { kind: 'mapper', values: new Set(Object.values(args.mappings)) }; + } + if(name === 'container') { + if(!Array.isArray(args)) return unknown; + const inner = { parent: scope, fields: new Map() }; + args.forEach((field, index) => { + const fieldPath = path + '[' + index + ']' + (field.name ? '.' + field.name : ''); + // Anonymous values can merge arbitrary names into this container. Their + // read/write parent contexts need not agree, so do not propagate either. + if(field.anon) { + walk(field.type, null, definitions, fieldPath, active); + inner.fields.clear(); + inner.parent = null; + } else { + const value = walk(field.type, inner, definitions, fieldPath, active); + inner.fields.set(field.name, value); + } + }); + return { kind: 'container', fields: inner.fields }; + } + if(name === 'switch') { + if(!args || hasParameters(args.compareTo)) return unknown; + checkSwitch(args, scope, path); + Object.keys(args.fields).forEach(value => + walk(args.fields[value], scope, definitions, path + '.fields[' + JSON.stringify(value) + ']', active)); + if(args.default !== undefined) + walk(args.default, scope, definitions, path + '.default', active); + return unknown; + } + if(name === 'array') { + if(args) walk(args.type, scope, definitions, path + '.type', active); + return unknown; + } + if(name === 'option') { + walk(args, scope, definitions, path + '.type', active); + return unknown; + } + // The remaining intrinsic types do not introduce container fields. Custom + // types are opaque: their arguments do not establish traversal or scope rules. + return unknown; + } + + return walk; +} + +function validateType(type, schemas) { + createChecker(schemas)(type, null, new Map(), 'type', new Set()); +} + +function validateProtocol(protocol, schemas) { + const walk = createChecker(schemas); + function visit(node, inherited, path) { + const definitions = new Map(inherited); + const types = node.types || {}; + Object.keys(types).forEach(name => { + const type = types[name]; + // Type override precedence differs between consumers. Repeated native + // declarations are harmless; other collisions cannot establish a domain. + definitions.set(name, inherited.has(name) && + !(type === 'native' && inherited.get(name) === 'native') ? null : type); + }); + Object.keys(types).forEach(name => { + if(definitions.get(name) === null || + (Object.prototype.hasOwnProperty.call(builtinSchemas, name) && types[name] !== 'native') || + hasParameters(types[name])) return; + walk(types[name], null, definitions, path + '.types.' + name, new Set([name])); + }); + Object.keys(node).forEach(name => { + if(name !== 'types') visit(node[name], definitions, path + '.' + name); + }); + } + visit(protocol, new Map(), 'root'); +} + +module.exports = { validateType, validateProtocol };