Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/getNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GetNodeOptions, isSchemaNode, SchemaNode } from "./SchemaNode";
import { isJsonError, NodeOrError, OptionalNodeOrError } from "./types";
import { split } from "@sagold/json-pointer";
import { getValue } from "./utils/getValue";
import { appendDataPointer } from "./utils/appendDataPointer";

// prettier-ignore
export function getNode(pointer: string, data: unknown, options: { withSchemaWarning: true } & GetNodeOptions): NodeOrError;
Expand Down Expand Up @@ -42,7 +43,7 @@ export function getNode(
let currentPointer = "#";
let currentNode = node;
for (let i = 0, l = keys.length; i < l; i += 1) {
currentPointer = `${currentPointer}/${keys[i]}`;
currentPointer = appendDataPointer(currentPointer, keys[i]);
const result = currentNode.getNodeChild(keys[i], data, { ...options, pointer: currentPointer });
if (result.error) {
return result;
Expand Down
5 changes: 3 additions & 2 deletions src/keywords/additionalProperties.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendDataPointer } from "../utils/appendDataPointer";
import settings from "../settings";
import { isObject } from "../utils/isObject";
import { Keyword, JsonSchemaResolverParams, JsonSchemaValidatorParams, ValidationReturnType } from "../Keyword";
Expand Down Expand Up @@ -106,7 +107,7 @@ function validateAdditionalProperty({ node, data, pointer = "#", path }: JsonSch
const validationErrors = validateNode(
node.additionalProperties,
propertyValue,
`${pointer}/${property}`,
appendDataPointer(pointer, property),
path
);
if (validationErrors) {
Expand All @@ -116,7 +117,7 @@ function validateAdditionalProperty({ node, data, pointer = "#", path }: JsonSch
} else {
errors.push(
node.createError("no-additional-properties-error", {
pointer: `${pointer}/${property}`,
pointer: appendDataPointer(pointer, property),
schema,
value: data,
property,
Expand Down
10 changes: 8 additions & 2 deletions src/keywords/oneOf.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendDataPointer } from "../utils/appendDataPointer";
import {
Keyword,
JsonSchemaReducerParams,
Expand Down Expand Up @@ -161,7 +162,7 @@ export function reduceOneOfDeclarator({ node, data, pointer, path }: Omit<JsonSc

// collect errors in case we fail finding a matching schema
const result = sanitizeErrors(
validateNode(resultNode, oneOfPropertyValue, `${pointer}/${oneOfProperty}`, path)
validateNode(resultNode, oneOfPropertyValue, appendDataPointer(pointer, oneOfProperty), path)
);

if (result.length > 0) {
Expand Down Expand Up @@ -317,7 +318,12 @@ function validateFromDeclarator({ node, data, pointer = "#", path }: JsonSchemaV
for (const oneOfNode of oneOf) {
const { node: oneOfPropertyNode, error } = oneOfNode.getNodeChild(oneOfProperty, oneOfValue);
if (oneOfPropertyNode) {
const validationResult = validateNode(oneOfPropertyNode, oneOfValue, `${pointer}/${oneOfProperty}`, path);
const validationResult = validateNode(
oneOfPropertyNode,
oneOfValue,
appendDataPointer(pointer, oneOfProperty),
path
);
if (validationResult.length > 0) {
errors.push(...validationResult);
} else {
Expand Down
7 changes: 5 additions & 2 deletions src/keywords/patternProperties.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendDataPointer } from "../utils/appendDataPointer";
import { mergeSchema } from "../utils/mergeSchema";
import { JsonSchema, SchemaNode } from "../types";
import { isObject } from "../utils/isObject";
Expand Down Expand Up @@ -107,7 +108,9 @@ function validatePatternProperties({ node, data, pointer, path }: JsonSchemaVali
const value = getValue(data, key);
// patternProperties was tested in addValidate
const matchingPatterns = patternProperties!.filter((property) => property.pattern.test(key));
matchingPatterns.forEach(({ node }) => errors.push(...validateNode(node, value, `${pointer}/${key}`, path)));
matchingPatterns.forEach(({ node }) =>
errors.push(...validateNode(node, value, appendDataPointer(pointer, key), path))
);

if (properties[key]) {
return;
Expand All @@ -118,7 +121,7 @@ function validatePatternProperties({ node, data, pointer, path }: JsonSchemaVali
errors.push(
node.createError("no-additional-properties-error", {
key,
pointer: `${pointer}/${key}`,
pointer: appendDataPointer(pointer, key),
schema,
value,
patterns
Expand Down
3 changes: 2 additions & 1 deletion src/keywords/properties.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendDataPointer } from "../utils/appendDataPointer";
import { getValue } from "../utils/getValue";
import { SchemaNode } from "../types";
import {
Expand Down Expand Up @@ -71,7 +72,7 @@ function validateProperties({ node, data, pointer, path }: JsonSchemaValidatorPa
return;
}

const result = validateNode(propertyNode, value, `${pointer}/${propertyName}`, path);
const result = validateNode(propertyNode, value, appendDataPointer(pointer, propertyName), path);
errors.push(...result);
});
return errors;
Expand Down
3 changes: 2 additions & 1 deletion src/keywords/propertyNames.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendDataPointer } from "../utils/appendDataPointer";
import { isBooleanSchema, isJsonSchema, JsonError } from "../types";
import { isObject } from "../utils/isObject";
import { SchemaNode } from "../types";
Expand Down Expand Up @@ -71,7 +72,7 @@ function validatePropertyNames({ node, data, pointer, path }: JsonSchemaValidato
const errors: JsonError[] = [];
const properties = Object.keys(data);
properties.forEach((prop) => {
const validationResult = validateNode(propertyNames, prop, `${pointer}/${prop}`, path);
const validationResult = validateNode(propertyNames, prop, appendDataPointer(pointer, prop), path);
if (validationResult.length > 0) {
errors.push(
node.createError("invalid-property-name-error", {
Expand Down
12 changes: 7 additions & 5 deletions src/keywords/unevaluatedProperties.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendDataPointer } from "../utils/appendDataPointer";
import { isObject } from "../utils/isObject";
import { isBooleanSchema, isJsonSchema, SchemaNode } from "../types";
import { Keyword, JsonSchemaValidatorParams, ValidationReturnType } from "../Keyword";
Expand Down Expand Up @@ -68,40 +69,41 @@ function validateUnevaluatedProperties({ node, data, pointer, path }: JsonSchema
}

const { node: child } = node.getNodeChild(propertyName, data, { pointer, path });
const propertyPointer = appendDataPointer(pointer, propertyName);

if (child === undefined) {
if (node.unevaluatedProperties) {
const validationResult = validateNode(
node.unevaluatedProperties,
data[propertyName],
`${pointer}/${propertyName}`,
propertyPointer,
path
);
errors.push(...validationResult);
} else if (node.schema.unevaluatedProperties === false) {
errors.push(
node.createError("unevaluated-property-error", {
pointer: `${pointer}/${propertyName}`,
pointer: propertyPointer,
value: JSON.stringify(data[propertyName]),
schema: node.schema
})
);
}
}

if (child && validateNode(child, data[propertyName], `${pointer}/${propertyName}`, path).length > 0) {
if (child && validateNode(child, data[propertyName], propertyPointer, path).length > 0) {
if (node.unevaluatedProperties) {
const validationResult = validateNode(
node.unevaluatedProperties,
data[propertyName],
`${pointer}/${propertyName}`,
propertyPointer,
path
);
errors.push(...validationResult);
} else if (node.schema.unevaluatedProperties === false) {
errors.push(
node.createError("unevaluated-property-error", {
pointer: `${pointer}/${propertyName}`,
pointer: propertyPointer,
value: JSON.stringify(data[propertyName]),
schema: node.schema
})
Expand Down
5 changes: 3 additions & 2 deletions src/methods/toDataNodes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendDataPointer } from "../utils/appendDataPointer";
import { getValue } from "../utils/getValue";
import { isObject } from "../utils/isObject";
import { SchemaNode } from "../types";
Expand All @@ -16,14 +17,14 @@ export function toDataNodes(node: SchemaNode, data: unknown, pointer = "#", data
Object.keys(data).forEach((key) => {
const { node: nextNode } = currentNode.getNodeChild(key, data);
if (nextNode) {
toDataNodes(nextNode, getValue(data, key), `${pointer}/${key}`, dataNodes);
toDataNodes(nextNode, getValue(data, key), appendDataPointer(pointer, key), dataNodes);
}
});
} else if (Array.isArray(data)) {
data.forEach((next: unknown, key: number) => {
const { node: nextNode } = currentNode.getNodeChild(key, data);
if (nextNode) {
toDataNodes(nextNode, getValue(data, key), `${pointer}/${key}`, dataNodes);
toDataNodes(nextNode, getValue(data, key), appendDataPointer(pointer, key), dataNodes);
}
});
}
Expand Down
165 changes: 165 additions & 0 deletions src/tests/issues/data-pointer-escaping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { strict as assert } from "assert";
import { compileSchema } from "../../compileSchema";
import { isJsonError, JsonSchema } from "../../types";

describe("data pointer escaping", () => {
it("distinguishes nested property names containing slashes", () => {
const node = compileSchema({
properties: {
"a/b": { properties: { c: { type: "number" } } },
a: { properties: { "b/c": { type: "number" } } }
}
});
const { valid, errors } = node.validate({ "a/b": { c: "invalid" }, a: { "b/c": "invalid" } });

assert.equal(valid, false);
assert.deepEqual(errors.map((error) => error.data.pointer), ["#/a~1b/c", "#/a/b~1c"]);
assert.equal(node.validate({ "a/b": { c: 1 }, a: { "b/c": 2 } }).valid, true);
});

it("preserves prefixes, empty names and array indexes without re-escaping ancestors", () => {
const node = compileSchema({
properties: { "~1/": { properties: { "": { items: { type: "number" } } } } }
});
for (const prefix of ["#", "", "/parent~1name", "custom/root~0/"]) {
const { valid, errors } = node.validate({ "~1/": { "": ["invalid"] } }, prefix);
assert.equal(valid, false);
assert.deepEqual(errors.map((error) => error.data.pointer), [`${prefix}/~01~1//0`]);
}
assert.equal(node.validate({ "~1/": { "": [1] } }).valid, true);
});

const cases: { name: string; schema: JsonSchema; validData: unknown; codes: string[] }[] = [
{
name: "additionalProperties schema",
schema: { additionalProperties: { type: "number" } },
validData: { "a/b~": 1 },
codes: ["type-error"]
},
{
name: "additionalProperties false",
schema: { additionalProperties: false },
validData: {},
codes: ["no-additional-properties-error"]
},
{
name: "matching patternProperties",
schema: { patternProperties: { "^a": { type: "number" } } },
validData: { "a/b~": 1 },
codes: ["type-error"]
},
{
name: "unmatched patternProperties with additionalProperties false",
schema: { patternProperties: { "^z": {} }, additionalProperties: false },
validData: { z: true },
codes: ["no-additional-properties-error"]
},
{
name: "unevaluatedProperties schema without a child schema",
schema: { unevaluatedProperties: { type: "number" } },
validData: { "a/b~": 1 },
codes: ["type-error"]
},
{
name: "unevaluatedProperties false without a child schema",
schema: { unevaluatedProperties: false },
validData: {},
codes: ["unevaluated-property-error"]
},
{
name: "unevaluatedProperties schema with an invalid child",
schema: {
allOf: [{ properties: { "a/b~": { type: "string" } } }],
unevaluatedProperties: { type: "number" }
},
validData: { "a/b~": "valid" },
codes: ["type-error", "type-error"]
},
{
name: "unevaluatedProperties false with an invalid child",
schema: {
allOf: [{ properties: { "a/b~": { type: "string" } } }],
unevaluatedProperties: false
},
validData: { "a/b~": "valid" },
codes: ["type-error", "unevaluated-property-error"]
}
];

for (const { name, schema, validData, codes } of cases) {
it(`escapes property names in ${name} errors`, () => {
const node = compileSchema(schema);
const { valid, errors } = node.validate({ "a/b~": true });

assert.equal(valid, false);
assert.deepEqual(errors.map((error) => error.code), codes);
assert.deepEqual(errors.map((error) => error.data.pointer), codes.map(() => "#/a~1b~0"));
assert.equal(node.validate(validData).valid, true);
});
}

it("escapes the nested propertyNames error while retaining the containing object pointer", () => {
const node = compileSchema({ propertyNames: { pattern: "^[a-z]+$" } });
const { valid, errors } = node.validate({ "a/b~": 1 });

assert.equal(valid, false);
assert.equal(errors.length, 1);
assert.equal(errors[0].code, "invalid-property-name-error");
assert.equal(errors[0].data.pointer, "#");
const validationError = errors[0].data.validationError;
assert(isJsonError(validationError));
assert.equal(validationError.data.pointer, "#/a~1b~0");
assert.equal(node.validate({ abc: 1 }).valid, true);
});

for (const method of ["validate", "reduceNode"] as const) {
it(`escapes nested oneOf declarator errors from ${method}`, () => {
const node = compileSchema({
oneOfProperty: "a/b~",
oneOf: [
{ properties: { "a/b~": { const: "first" } } },
{ properties: { "a/b~": { const: "second" } } }
]
});
const data = { "a/b~": "unknown" };
const error = method === "validate" ? node.validate(data).errors[0] : node.reduceNode(data).error;

assert(error);
assert.equal(error.code, method === "validate" ? "one-of-error" : "one-of-property-error");
const nestedErrors = error.data.errors;
assert(Array.isArray(nestedErrors));
assert.equal(nestedErrors.length, 2);
for (const nestedError of nestedErrors) {
assert(isJsonError(nestedError));
assert.equal(nestedError.data.pointer, "#/a~1b~0");
}
assert.equal(node.validate({ "a/b~": "first" }).valid, true);
assert(node.reduceNode({ "a/b~": "first" }).node);
});
}

it("escapes traversal warning pointers in getNode", () => {
const node = compileSchema({ properties: { "a/b": {} } });
const { error } = node.getNode("#/a~1b/~0", { "a/b": { "~": 1 } }, { withSchemaWarning: true });

assert(error);
assert.equal(error.code, "schema-warning");
assert.equal(error.data.pointer, "#/a~1b/~0");
});

it("escapes toDataNodes pointers while preserving values, prefixes, empty names and indexes", () => {
const node = compileSchema({
properties: { "~1/": { properties: { "": { items: { type: "number" } } } } }
});
const data = { "~1/": { "": [1] } };
for (const prefix of ["#", "", "/parent~1name", "custom/root~0/"]) {
const nodes = node.toDataNodes(data, prefix);
assert.deepEqual(nodes.map(({ pointer, value }) => [pointer, value]), [
[prefix, data],
[`${prefix}/~01~1`, { "": [1] }],
[`${prefix}/~01~1/`, [1]],
[`${prefix}/~01~1//0`, 1]
]);
}
});
});
6 changes: 6 additions & 0 deletions src/utils/appendDataPointer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { join } from "@sagold/json-pointer";

/** Append a literal property or index without reinterpreting the existing pointer. */
export function appendDataPointer(pointer: string, key: string | number) {
return pointer + join([String(key)]);
}