@exodus/schemasafe
Advanced tools
Comparing version 1.0.0-rc.8 to 1.0.0-rc.9
{ | ||
"name": "@exodus/schemasafe", | ||
"version": "1.0.0-rc.8", | ||
"version": "1.0.0-rc.9", | ||
"description": "JSON Safe Parser & Schema Validator", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
@@ -23,3 +23,3 @@ # `@exodus/schemasafe` | ||
_E.g. it will detect mistakes like `{type: "array", "maxLength": 2}`._ | ||
* [Less than 2000 lines of code](./doc/Auditable.md), non-minified. | ||
* [About 2000 lines of code](./doc/Auditable.md), non-minified. | ||
* Uses [secure code generation](./doc/Secure-code-generation.md) approach to prevent data from schema from leaking into | ||
@@ -26,0 +26,0 @@ the generated code without being JSON-wrapped. |
@@ -87,3 +87,3 @@ 'use strict' | ||
) | ||
return `(function() {\n'use strict'\n${scopeDefs.join('\n')}\n${build()}})();` | ||
return `(function() {\n'use strict'\n${scopeDefs.join('\n')}\n${build()}})()` | ||
}, | ||
@@ -90,0 +90,0 @@ |
136
src/index.js
@@ -6,31 +6,52 @@ 'use strict' | ||
const { compile } = require('./compile') | ||
const functions = require('./scope-functions') | ||
const { deepEqual } = require('./scope-functions') | ||
const validator = (schema, { jsonCheck = false, isJSON = false, schemas, ...opts } = {}) => { | ||
const jsonCheckWithErrors = (validate) => | ||
function validateIsJSON(data) { | ||
if (!deepEqual(data, JSON.parse(JSON.stringify(data)))) { | ||
validateIsJSON.errors = [{ instanceLocation: '#', error: 'not JSON compatible' }] | ||
return false | ||
} | ||
const res = validate(data) | ||
validateIsJSON.errors = validate.errors | ||
return res | ||
} | ||
const jsonCheckWithoutErrors = (validate) => (data) => | ||
deepEqual(data, JSON.parse(JSON.stringify(data))) && validate(data) | ||
const validator = ( | ||
schema, | ||
{ parse = false, multi = false, jsonCheck = false, isJSON = false, schemas = [], ...opts } = {} | ||
) => { | ||
if (jsonCheck && isJSON) throw new Error('Can not specify both isJSON and jsonCheck options') | ||
const options = { ...opts, schemas: buildSchemas(schemas || []), isJSON: isJSON || jsonCheck } | ||
const { scope, refs } = compile([schema], options) // only a single ref | ||
if (parse && (jsonCheck || isJSON)) | ||
throw new Error('jsonCheck and isJSON options are not applicable in parser mode') | ||
const mode = parse ? 'strong' : 'default' // strong mode is default in parser, can be overriden | ||
const willJSON = isJSON || jsonCheck || parse | ||
const arg = multi ? schema : [schema] | ||
const options = { mode, ...opts, schemas: buildSchemas(schemas, arg), isJSON: willJSON } | ||
const { scope, refs } = compile(arg, options) // only a single ref | ||
if (opts.dryRun) return | ||
const fun = genfun() | ||
if (!jsonCheck) { | ||
fun.write('%s', refs[0]) | ||
if (parse) { | ||
scope.parseWrap = opts.includeErrors ? parseWithErrors : parseWithoutErrors | ||
} else if (jsonCheck) { | ||
scope.deepEqual = deepEqual | ||
scope.jsonCheckWrap = opts.includeErrors ? jsonCheckWithErrors : jsonCheckWithoutErrors | ||
} | ||
if (multi) { | ||
fun.write('[') | ||
for (const ref of refs.slice(0, -1)) fun.write('%s,', ref) | ||
if (refs.length > 0) fun.write('%s', refs[refs.length - 1]) | ||
fun.write(']') | ||
if (parse) fun.write('.map(parseWrap)') | ||
else if (jsonCheck) fun.write('.map(jsonCheckWrap)') | ||
} else { | ||
// jsonCheck wrapper implementation below | ||
scope.deepEqual = functions.deepEqual | ||
fun.write('function validateIsJSON(data) {') | ||
if (opts.includeErrors) { | ||
fun.write('if (!deepEqual(data, JSON.parse(JSON.stringify(data)))) {') | ||
fun.write('validateIsJSON.errors = [{instanceLocation:"#",error:"not JSON compatible"}]') | ||
fun.write('return false') | ||
fun.write('}') | ||
fun.write('const res = %s(data)', refs[0]) | ||
fun.write('validateIsJSON.errors = actualValidate.errors') | ||
fun.write('return res') | ||
} else { | ||
fun.write('return deepEqual(data, JSON.parse(JSON.stringify(data))) && %s(data)', refs[0]) | ||
} | ||
fun.write('}') | ||
if (parse) fun.write('parseWrap(%s)', refs[0]) | ||
else if (jsonCheck) fun.write('jsonCheckWrap(%s)', refs[0]) | ||
else fun.write('%s', refs[0]) | ||
} | ||
const validate = fun.makeFunction(scope) | ||
validate.toModule = () => fun.makeModule(scope) | ||
validate.toModule = ({ semi = true } = {}) => fun.makeModule(scope) + (semi ? ';' : '') | ||
validate.toJSON = () => schema | ||
@@ -40,45 +61,34 @@ return validate | ||
const parser = function(schema, opts = {}) { | ||
// strong mode is default in parser | ||
if (functions.hasOwn(opts, 'jsonCheck') || functions.hasOwn(opts, 'isJSON')) | ||
throw new Error('jsonCheck and isJSON options are not applicable in parser mode') | ||
const validate = validator(schema, { mode: 'strong', ...opts, jsonCheck: false, isJSON: true }) | ||
const parse = opts.includeErrors | ||
? (src) => { | ||
if (typeof src !== 'string') return { valid: false, error: 'Input is not a string' } | ||
try { | ||
const value = JSON.parse(src) | ||
if (!validate(value)) { | ||
const { keywordLocation, instanceLocation } = validate.errors[0] | ||
const keyword = keywordLocation.slice(keywordLocation.lastIndexOf('/') + 1) | ||
const error = `JSON validation failed for ${keyword} at ${instanceLocation}` | ||
return { valid: false, error, errors: validate.errors } | ||
} | ||
return { valid: true, value } | ||
} catch ({ message }) { | ||
return { valid: false, error: message } | ||
} | ||
} | ||
: (src) => { | ||
if (typeof src !== 'string') return { valid: false } | ||
try { | ||
const value = JSON.parse(src) | ||
if (!validate(value)) return { valid: false } | ||
return { valid: true, value } | ||
} catch (e) { | ||
return { valid: false } | ||
} | ||
} | ||
parse.toModule = () => | ||
[ | ||
'(function(src) {', | ||
`const validate = ${validate.toModule()}`, | ||
`const parse = ${parse}\n`, | ||
'return parse(src)', | ||
'});', | ||
].join('\n') | ||
parse.toJSON = () => schema | ||
return parse | ||
const parseWithErrors = (validate) => (src) => { | ||
if (typeof src !== 'string') return { valid: false, error: 'Input is not a string' } | ||
try { | ||
const value = JSON.parse(src) | ||
if (!validate(value)) { | ||
const { keywordLocation, instanceLocation } = validate.errors[0] | ||
const keyword = keywordLocation.slice(keywordLocation.lastIndexOf('/') + 1) | ||
const error = `JSON validation failed for ${keyword} at ${instanceLocation}` | ||
return { valid: false, error, errors: validate.errors } | ||
} | ||
return { valid: true, value } | ||
} catch ({ message }) { | ||
return { valid: false, error: message } | ||
} | ||
} | ||
const parseWithoutErrors = (validate) => (src) => { | ||
if (typeof src !== 'string') return { valid: false } | ||
try { | ||
const value = JSON.parse(src) | ||
if (!validate(value)) return { valid: false } | ||
return { valid: true, value } | ||
} catch (e) { | ||
return { valid: false } | ||
} | ||
} | ||
const parser = function(schema, { parse = true, ...opts } = {}) { | ||
if (!parse) throw new Error('can not disable parse in parser') | ||
return validator(schema, { parse, ...opts }) | ||
} | ||
module.exports = { validator, parser } |
@@ -5,2 +5,7 @@ 'use strict' | ||
function safeSet(map, key, value, comment = 'keys') { | ||
if (!map.has(key)) return map.set(key, value) | ||
if (map.get(key) !== value) throw new Error(`Conflicting duplicate ${comment}: ${key}`) | ||
} | ||
function untilde(string) { | ||
@@ -58,11 +63,25 @@ if (!string.includes('~')) return string | ||
const withSpecialChilds = ['properties', 'patternProperties', '$defs', 'definitions'] | ||
const skipChilds = ['const', 'enum', 'examples', 'example', 'comment'] | ||
const sSkip = Symbol('skip') | ||
function traverse(schema, work) { | ||
const visit = (sub, specialChilds = false) => { | ||
if (!sub || typeof sub !== 'object') return | ||
const res = work(sub) | ||
if (res !== undefined) return res | ||
if (res === sSkip) return | ||
for (const k of Object.keys(sub)) { | ||
if (!specialChilds && !Array.isArray(sub) && !knownKeywords.includes(k)) continue | ||
if (!specialChilds && skipChilds.includes(k)) continue | ||
const kres = visit(sub[k], !specialChilds && withSpecialChilds.includes(k)) | ||
if (kres !== undefined) return kres | ||
} | ||
} | ||
return visit(schema) | ||
} | ||
// Returns a list of resolved entries, in a form: [schema, root, basePath] | ||
// basePath doesn't contain the target object $id itself | ||
function resolveReference(root, additionalSchemas, ref, base = '') { | ||
function resolveReference(root, schemas, ref, base = '') { | ||
const ptr = joinPath(base, ref) | ||
const schemas = new Map(additionalSchemas) | ||
const self = (base || '').split('#')[0] | ||
if (self) schemas.set(self, root) | ||
const results = [] | ||
@@ -76,2 +95,3 @@ | ||
if (!sub || typeof sub !== 'object') return | ||
const id = sub.$id || sub.id | ||
@@ -96,5 +116,6 @@ let path = oldPath | ||
} | ||
for (const k of Object.keys(sub)) { | ||
if (!specialChilds && !Array.isArray(sub) && !knownKeywords.includes(k)) continue | ||
if (!specialChilds && ['const', 'enum', 'examples', 'comment'].includes(k)) continue | ||
if (!specialChilds && skipChilds.includes(k)) continue | ||
visit(sub[k], path, !specialChilds && withSpecialChilds.includes(k)) | ||
@@ -115,3 +136,3 @@ } | ||
if (schemas.has(main)) { | ||
const additional = resolveReference(schemas.get(main), additionalSchemas, `#${hash}`) | ||
const additional = resolveReference(schemas.get(main), schemas, `#${hash}`) | ||
results.push(...additional.map(([res, rRoot, rPath]) => [res, rRoot, joinPath(main, rPath)])) | ||
@@ -128,5 +149,4 @@ } | ||
const results = new Map() | ||
const visit = (sub, specialChilds = false) => { | ||
if (!sub || typeof sub !== 'object') return | ||
if (sub !== schema && (sub.$id || sub.id)) return // base changed, no longer in the same resource | ||
traverse(schema, (sub) => { | ||
if (sub !== schema && (sub.$id || sub.id)) return sSkip // base changed, no longer in the same resource | ||
const anchor = sub.$dynamicAnchor | ||
@@ -136,30 +156,30 @@ if (anchor && typeof anchor === 'string') { | ||
if (!/^[a-zA-Z0-9_-]+$/.test(anchor)) throw new Error(`Unsupported $dynamicAnchor: ${anchor}`) | ||
if (results.has(anchor)) throw new Error(`duplicate $dynamicAnchor: ${anchor}`) | ||
results.set(anchor, sub) | ||
safeSet(results, anchor, sub, '$dynamicAnchor') | ||
} | ||
for (const k of Object.keys(sub)) { | ||
if (!specialChilds && !Array.isArray(sub) && !knownKeywords.includes(k)) continue | ||
if (!specialChilds && ['const', 'enum', 'examples', 'comment'].includes(k)) continue | ||
visit(sub[k], !specialChilds && withSpecialChilds.includes(k)) | ||
} | ||
} | ||
visit(schema) | ||
}) | ||
return results | ||
} | ||
function hasKeywords(schema, keywords) { | ||
const visit = (sub, specialChilds = false) => { | ||
if (!sub || typeof sub !== 'object') return false | ||
for (const k of Object.keys(sub)) { | ||
if (keywords.includes(k)) return true | ||
if (!specialChilds && !Array.isArray(sub) && !knownKeywords.includes(k)) continue | ||
if (!specialChilds && ['const', 'enum', 'examples', 'comment'].includes(k)) continue | ||
if (visit(sub[k], !specialChilds && withSpecialChilds.includes(k))) return true | ||
} | ||
return false | ||
const hasKeywords = (schema, keywords) => | ||
traverse(schema, (s) => Object.keys(s).some((k) => keywords.includes(k)) || undefined) || false | ||
const addSchemasArrayToMap = (schemas, input, optional = false) => { | ||
if (!Array.isArray(input)) throw new Error('Expected an array of schemas') | ||
// schema ids are extracted from the schemas themselves | ||
for (const schema of input) { | ||
traverse(schema, (sub) => { | ||
const idRaw = sub.$id || sub.id | ||
const id = idRaw && typeof idRaw === 'string' ? idRaw.replace(/#$/, '') : null // # is allowed only as the last symbol here | ||
if (id && id.includes('://') && !id.includes('#')) { | ||
safeSet(schemas, id, sub, "schema $id in 'schemas'") | ||
} else if (sub === schema && !optional) { | ||
throw new Error("Schema with missing or invalid $id in 'schemas'") | ||
} | ||
}) | ||
} | ||
return visit(schema) | ||
return schemas | ||
} | ||
const buildSchemas = (input) => { | ||
const buildSchemas = (input, extra) => { | ||
if (extra) return addSchemasArrayToMap(buildSchemas(input), extra, true) | ||
if (input) { | ||
@@ -172,22 +192,3 @@ switch (Object.getPrototypeOf(input)) { | ||
case Array.prototype: { | ||
// In this case, schema ids are extracted from the schemas themselves | ||
const schemas = new Map() | ||
const cleanId = (id) => | ||
// # is allowed only as the last symbol here | ||
id && typeof id === 'string' && !/#./.test(id) ? id.replace(/#$/, '') : null | ||
for (const schema of input) { | ||
const visit = (sub) => { | ||
if (!sub || typeof sub !== 'object') return | ||
const id = cleanId(sub.$id || sub.id) | ||
if (id && id.includes('://')) { | ||
if (schemas.has(id)) throw new Error("Duplicate schema $id in 'schemas'") | ||
schemas.set(id, sub) | ||
} else if (sub === schema) { | ||
throw new Error("Schema with missing or invalid $id in 'schemas'") | ||
} | ||
for (const k of Object.keys(sub)) visit(sub[k]) | ||
} | ||
visit(schema) | ||
} | ||
return schemas | ||
return addSchemasArrayToMap(new Map(), input) | ||
} | ||
@@ -194,0 +195,0 @@ } |
Sorry, the diff of this file is too big to display
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
134112
2415