Skip to content

Commit 7447189

Browse files
committed
fix: added encoding on absolutePointer before decodeURIComponent
1 parent 9be4c9c commit 7447189

File tree

13 files changed

+149
-76
lines changed

13 files changed

+149
-76
lines changed

.changeset/solid-lands-repair.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@redocly/openapi-core": patch
3+
---
4+
5+
Fixed an issue where JSON Pointers containing special characters (like `%`) were not properly URI-encoded.
6+
When these pointers were used as URI identifiers, they caused validation errors with properties containing percent signs or other special characters.

.github/workflows/performance.yaml

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,7 @@ env:
1414
REDOCLY_TELEMETRY: off
1515

1616
jobs:
17-
latest-vs-next:
18-
runs-on: ubuntu-latest
19-
steps:
20-
- uses: actions/checkout@v4
21-
- uses: actions/setup-node@v3
22-
with:
23-
node-version: 24
24-
cache: npm
25-
- name: Install Dependencies
26-
run: npm ci
27-
- name: Install External
28-
run: npm i -g hyperfine @redocly/cli@latest
29-
- name: Prepare
30-
run: |
31-
jq '.bin = {"redocly-next":"bin/cli.js"} | .name = "@redocly/cli-next"' packages/cli/package.json > __tmp__.json
32-
mv __tmp__.json packages/cli/package.json
33-
cat packages/cli/package.json
34-
npm run pack:prepare
35-
npm i -g redocly-cli.tgz
36-
- run: redocly-next --version
37-
- run: redocly --version
38-
- name: Run Benchmark
39-
run: hyperfine -i --warmup 3 'redocly lint resources/rebilly.yaml' 'redocly-next lint resources/rebilly.yaml' --export-markdown benchmark_check.md --export-json benchmark_check.json
40-
41-
- name: Comment PR
42-
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
43-
uses: thollander/actions-comment-pull-request@v2
44-
with:
45-
filePath: benchmark_check.md
46-
comment_tag: latest-vs-next-comparison
47-
4817
historical-versions:
49-
# Run only on release branch (changeset-release/main):
50-
if: github.head_ref == 'changeset-release/main'
5118
runs-on: ubuntu-latest
5219
steps:
5320
- uses: actions/checkout@v4
@@ -59,17 +26,27 @@ jobs:
5926
run: npm ci
6027
- name: Install External
6128
run: npm i -g hyperfine
29+
30+
- name: Add more versions to test
31+
# Run only on the release branch (changeset-release/main):
32+
if: github.head_ref == 'changeset-release/main'
33+
run: |
34+
cd tests/performance/
35+
cat package.json | jq ".dependencies = $(cat package.json | jq ._enhancedDependencies)" > package.json
36+
cat package.json
37+
6238
- name: Prepare
6339
run: |
6440
npm run compile
6541
npm run pack:prepare
6642
cd tests/performance/
6743
npm i
6844
npm run make-test
45+
6946
- name: Run Benchmark
7047
run: |
7148
cd tests/performance/
72-
npm test # This command is generated and injected into package.json in the previous step.
49+
npm run test # This command is generated and injected into package.json in the previous step.
7350
cat benchmark_check.md
7451
npm run chart # Creates benchmark_chart.md with the performance bar chart.
7552

CONTRIBUTING.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,16 @@ For other commands you'd have to do something similar.
286286
### Performance benchmark
287287

288288
To run the performance tests locally, you should have `hyperfine` (v1.16.1+) installed on your machine.
289-
Prepare the local build, go to the `tests/performance` folder, clean it up, do the preparations, and run the actual test:
289+
Prepare the local build, go to the `tests/performance` folder, clean it up, do the preparations:
290290

291291
```sh
292-
(npm run compile && npm run pack:prepare && cd tests/performance/ && git clean -dX -f . && git clean -dX -ff . && npm i && npm run make-test && npm test)
292+
(npm run compile && npm run pack:prepare && cd tests/performance/ && git clean -dX -f . && git clean -dX -ff . && rm -rf node_modules && rm -f package-lock.json && npm i && npm run make-test)
293+
```
294+
295+
and run the actual test:
296+
297+
```sh
298+
(cd tests/performance/ && npm run test && cat benchmark_check.md)
293299
```
294300

295301
You might need to adjust the CLI versions that need to be tested in the `tests/performance/package.json` file.

packages/core/src/__tests__/ref-utils.test.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import outdent from 'outdent';
22
import { parseYamlToDocument } from '../../__tests__/utils.js';
3-
import { parseRef, refBaseName } from '../ref-utils.js';
3+
import {
4+
escapePointerFragment,
5+
parseRef,
6+
refBaseName,
7+
unescapePointerFragment,
8+
} from '../ref-utils.js';
49
import { lintDocument } from '../lint.js';
510
import { createConfig } from '../config/index.js';
611
import { BaseResolver } from '../resolve.js';
@@ -35,8 +40,8 @@ describe('ref-utils', () => {
3540
});
3641

3742
it(`should unescape complex urlencoded paths`, () => {
38-
const referene = 'somefile.yaml#/components/schemas/scope%2Fcomplex~name';
39-
expect(parseRef(referene)).toMatchInlineSnapshot(`
43+
const reference = 'somefile.yaml#/components/schemas/scope%2Fcomplex~name';
44+
expect(parseRef(reference)).toMatchInlineSnapshot(`
4045
{
4146
"pointer": [
4247
"components",
@@ -48,6 +53,20 @@ describe('ref-utils', () => {
4853
`);
4954
});
5055

56+
it(`should unescape escaped paths`, () => {
57+
const reference = 'somefile.yaml#/components/schemas/scope~1complex~0name with spaces';
58+
expect(parseRef(reference)).toMatchInlineSnapshot(`
59+
{
60+
"pointer": [
61+
"components",
62+
"schemas",
63+
"scope/complex~name with spaces",
64+
],
65+
"uri": "somefile.yaml",
66+
}
67+
`);
68+
});
69+
5170
it(`should validate definition with urlencoded paths`, async () => {
5271
const document = parseYamlToDocument(
5372
outdent`
@@ -139,4 +158,29 @@ describe('ref-utils', () => {
139158
expect(refBaseName('abcdefg')).toStrictEqual('abcdefg');
140159
});
141160
});
161+
162+
describe('escapePointerFragment', () => {
163+
it('should escape a simple pointer fragment with ~ and / correctly', () => {
164+
expect(escapePointerFragment('scope/complex~name')).toStrictEqual('scope~1complex~0name');
165+
});
166+
167+
it('should escape a pointer fragment with a number correctly', () => {
168+
expect(escapePointerFragment(123)).toStrictEqual(123);
169+
});
170+
171+
it('should not URI-encode other special characters when escaping pointer fragments per https://datatracker.ietf.org/doc/html/rfc6901#section-6', () => {
172+
expect(escapePointerFragment('curly{braces}')).toStrictEqual('curly{braces}');
173+
expect(escapePointerFragment('plus+')).toStrictEqual('plus+');
174+
});
175+
});
176+
177+
describe('unescapePointerFragment', () => {
178+
it('should unescape a pointer with a percent sign correctly', () => {
179+
expect(unescapePointerFragment('activity_level_%25')).toStrictEqual('activity_level_%');
180+
});
181+
182+
it('should unescape a pointer correctly', () => {
183+
expect(unescapePointerFragment('scope~1complex~0name')).toStrictEqual('scope/complex~name');
184+
});
185+
});
142186
});

packages/core/src/format/codeframes.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as yamlAst from 'yaml-ast-parser';
2-
import { unescapePointer } from '../ref-utils.js';
2+
import { parsePointer } from '../ref-utils.js';
33
import { colorize, colorOptions } from '../logger.js';
44

55
import type { LineColLocationObject, Loc, LocationObject } from '../walk.js';
@@ -14,11 +14,6 @@ type YAMLNode = YAMLMapping | YAMLMap | YAMLAnchorReference | YAMLSequence | YAM
1414
const MAX_LINE_LENGTH = 150;
1515
const MAX_CODEFRAME_LINES = 3;
1616

17-
// TODO: temporary
18-
function parsePointer(pointer: string) {
19-
return pointer.substr(2).split('/').map(unescapePointer);
20-
}
21-
2217
export function getCodeframe(location: LineColLocationObject, color: boolean) {
2318
colorOptions.enabled = color;
2419
const { start, end = { line: start.line, col: start.col + 1 }, source } = location;
@@ -181,7 +176,8 @@ function positionsToLoc(
181176
}
182177

183178
export function getAstNodeByPointer(root: YAMLNode, pointer: string, reportOnKey: boolean) {
184-
const pointerSegments = parsePointer(pointer);
179+
const pointerSegments = parsePointer(pointer.substr(2));
180+
185181
if (root === undefined) {
186182
return undefined;
187183
}

packages/core/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ export {
5252
export { YamlParseError } from './errors/yaml-parse-error.js';
5353
export { parseYaml, stringifyYaml } from './js-yaml/index.js';
5454
export {
55-
unescapePointer,
55+
unescapePointerFragment,
5656
isRef,
5757
isAbsoluteUrl,
58-
escapePointer,
58+
escapePointerFragment,
5959
type Location,
6060
} from './ref-utils.js';
6161
export { detectSpec } from './detect-spec.js';

packages/core/src/ref-utils.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class Location {
2626
this.source,
2727
joinPointer(
2828
this.pointer,
29-
(Array.isArray(components) ? components : [components]).map(escapePointer).join('/')
29+
(Array.isArray(components) ? components : [components]).map(escapePointerFragment).join('/')
3030
)
3131
);
3232
}
@@ -40,13 +40,19 @@ export class Location {
4040
}
4141
}
4242

43-
export function unescapePointer(fragment: string): string {
44-
return decodeURIComponent(fragment.replace(/~1/g, '/').replace(/~0/g, '~'));
43+
export function unescapePointerFragment(fragment: string): string {
44+
const unescaped = fragment.replace(/~1/g, '/').replace(/~0/g, '~');
45+
46+
try {
47+
return decodeURIComponent(unescaped);
48+
} catch (e) {
49+
return unescaped;
50+
}
4551
}
4652

47-
export function escapePointer<T extends string | number>(fragment: T): T {
53+
export function escapePointerFragment<T extends string | number>(fragment: T): T {
4854
if (typeof fragment === 'number') return fragment;
49-
return (fragment as string).replace(/~/g, '~0').replace(/\//g, '~1') as T;
55+
return fragment.replaceAll('~', '~0').replaceAll('/', '~1') as T;
5056
}
5157

5258
export function parseRef(ref: string): { uri: string | null; pointer: string[] } {
@@ -58,7 +64,7 @@ export function parseRef(ref: string): { uri: string | null; pointer: string[] }
5864
}
5965

6066
export function parsePointer(pointer: string) {
61-
return pointer.split('/').map(unescapePointer).filter(isTruthy);
67+
return pointer.split('/').map(unescapePointerFragment).filter(isTruthy);
6268
}
6369

6470
export function pointerBaseName(pointer: string) {

packages/core/src/resolve.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from 'node:path';
33
import {
44
isRef,
55
joinPointer,
6-
escapePointer,
6+
escapePointerFragment,
77
parseRef,
88
isAbsoluteUrl,
99
isAnchor,
@@ -315,7 +315,7 @@ export async function resolveDocument(opts: {
315315
continue;
316316
}
317317

318-
walk(propValue, propType, joinPointer(nodeAbsoluteRef, escapePointer(propName)));
318+
walk(propValue, propType, joinPointer(nodeAbsoluteRef, escapePointerFragment(propName)));
319319
}
320320

321321
if (isRef(node)) {
@@ -426,7 +426,10 @@ export async function resolveDocument(opts: {
426426
break;
427427
} else if (target[segment] !== undefined) {
428428
target = target[segment];
429-
resolvedRef.nodePointer = joinPointer(resolvedRef.nodePointer!, escapePointer(segment));
429+
resolvedRef.nodePointer = joinPointer(
430+
resolvedRef.nodePointer!,
431+
escapePointerFragment(segment)
432+
);
430433
} else if (isRef(target)) {
431434
resolvedRef = await followRef(targetDoc, target, pushRef(refStack, target));
432435
targetDoc = resolvedRef.document || targetDoc;
@@ -437,7 +440,10 @@ export async function resolveDocument(opts: {
437440
}
438441

439442
target = resolvedRef.node[segment];
440-
resolvedRef.nodePointer = joinPointer(resolvedRef.nodePointer!, escapePointer(segment));
443+
resolvedRef.nodePointer = joinPointer(
444+
resolvedRef.nodePointer!,
445+
escapePointerFragment(segment)
446+
);
441447
} else {
442448
target = undefined;
443449
break;

packages/core/src/rules/ajv.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import addFormats from 'ajv-formats';
22
import Ajv from '@redocly/ajv/dist/2020.js';
3-
import { escapePointer } from '../ref-utils.js';
3+
import { escapePointerFragment } from '../ref-utils.js';
44

55
import type { Location } from '../ref-utils.js';
66
import type { ValidateFunction, ErrorObject } from '@redocly/ajv/dist/2020.js';
@@ -44,12 +44,13 @@ function getAjvValidator(
4444
allowAdditionalProperties: boolean
4545
): ValidateFunction | undefined {
4646
const ajv = getAjv(resolve, allowAdditionalProperties);
47+
const $id = encodeURI(loc.absolutePointer);
4748

48-
if (!ajv.getSchema(loc.absolutePointer)) {
49-
ajv.addSchema({ $id: loc.absolutePointer, ...schema }, loc.absolutePointer);
49+
if (!ajv.getSchema($id)) {
50+
ajv.addSchema({ $id, ...schema }, $id);
5051
}
5152

52-
return ajv.getSchema(loc.absolutePointer);
53+
return ajv.getSchema($id);
5354
}
5455

5556
export function validateJsonSchema(
@@ -95,7 +96,7 @@ export function validateJsonSchema(
9596
if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties') {
9697
const property = error.params.additionalProperty || error.params.unevaluatedProperty;
9798
message = `${message} \`${property}\``;
98-
error.instancePath += '/' + escapePointer(property);
99+
error.instancePath += '/' + escapePointerFragment(property);
99100
}
100101

101102
return {

tests/e2e/lint/no-invalid-schema-examples-oas3.1-error/openapi.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ paths:
3939
examples:
4040
- test
4141
- 25
42+
three_%:
43+
type: number
44+
example: wrong
45+
4246
responses:
4347
'200':
4448
description: My 200 response

0 commit comments

Comments
 (0)