Skip to content

fix(compilers/openapi): resolve a percent-encoded $ref fragment - #234

Merged
OmarAlJarrah merged 2 commits into
mainfrom
fix/openapi-percent-encoded-ref
Aug 3, 2026
Merged

fix(compilers/openapi): resolve a percent-encoded $ref fragment#234
OmarAlJarrah merged 2 commits into
mainfrom
fix/openapi-percent-encoded-ref

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 2, 2026

Copy link
Copy Markdown
Member

Closes #40.

Summary

A $ref is a URI, and RFC 3986 allows its fragment to be percent-encoded — a component named
Foo-Bar may legitimately be addressed as #/components/schemas/Foo%2DBar. Reference resolution
split the ref by hand and compared the raw fragment text against the declared component names, so
every spec-correct escape missed.

Scope.InternalPointer now reads the ref with the resolver’s own accessors (references.Reference,
already a dependency of this package) rather than re-deriving the split. Agreeing with the library
that actually performs the resolution is the property that matters here: when the two disagree, the
compiler either calls a resolved reference unresolved, or interns a node at a coordinate no source
position spells. nodeview.InternalPointer was already written against those same two methods for
the cycle scan — this brings the resolution side into line and removes the second hand-rolled copy
that let them drift apart in the first place.

What the undecoded pointer cost

The reported symptom was the loudest of three, and the least damaging. All three are fixed by the
one change, because all three read the same pointer.

Before Diagnostic
Schema $ref Position degraded to any openapi/unresolved-ref (error)
Discriminator mapping Mapping entry dropped, union loses a dispatch branch openapi/unresolved-ref (error)
Identity (any of the above) One coordinate interned two type nodes none
Non-schema entries Anon IDs minted under .../My%2DResp/... none

The last two rows are the reason this is more than a resolution bug. The pointer returned by
InternalPointer is also an ID source. A pointer through an encoded component name reaches a
position an unencoded pointer also reaches, so the two interned separate nodes — both references
resolving, no diagnostic on either side, and a duplicate that irverify has no reason to call
dangling. For the components that are not schemas it was quieter still: those entries resolve
through the library, so the value arrived intact and no unresolved-ref was ever emitted; only the
pointer stayed encoded, and every ID hoisted beneath it inherited the encoding.

Behavior changes beyond percent-decoding

Two further divergences from the resolver disappear with the hand-rolled split, and are listed
rather than left to be discovered:

  • a fragment with leading or trailing whitespace now resolves (the resolver trims it);
  • a fragment containing a second # now ends at that #, as the resolver ends it.

Both strictly widen what resolves; no reference that resolved before stops. The document half is
still compared undecoded, which also matches the resolver — it trims that half but does not decode
it, so a self-reference must be spelled the way the file is named. There is a test pinning that
boundary so it reads as a decision rather than an oversight.

Deliberately out of scope

A percent-encoded $dynamicRef fragment still fails to match its $dynamicAnchor. That path is
this compiler’s own anchor index, not the library’s resolver, so the fix there is a decision about
our own behavior rather than one settled by matching a dependency — including whether $anchor
names should be decoded on the declaration side too. Filed as #233.

Test plan

  • TestInternalPointer_MatchesTheResolversNormalization (internal/resolve) — pins the
    normalization directly, and deliberately carries the same name as nodeview’s test of the same two
    accessors, so the pair is one grep apart and a dependency bump has to satisfy both. Covers
    %2D/%5F/%2E/%20/%25, an undecodable escape kept raw, %2F deepening the pointer rather
    than naming a component with a slash, whitespace, a second #, and the undecoded document half.
  • TestLowerComponentSchemas_PercentEncodedRefResolves — end-to-end for three names that are legal
    under OpenAPI’s own component-name rule (^[a-zA-Z0-9.\-_]+$), so the escape is the only thing
    under test.
  • TestLowerComponentSchemas_PercentEncodedRefHoistsAtTheDeclaredCoordinate — the duplicate-node
    case. It puts both spellings in one document deliberately: the encoded ref alone lands on a
    single node whichever way the pointer reads, and only its name is wrong, so a single-spelling
    fixture cannot see the duplicate at all.
  • TestLowerService_PercentEncodedEntryRefKeepsTheDeclaredCoordinate — the silent non-schema entry
    case, asserted on the response’s hoisted content schema.
  • TestLowerComponentSchemas_PercentEncodedDiscriminatorMapping — the dropped mapping entry.
    InternalPointer’s contract has always named discriminator mappings alongside $ref; nothing
    exercised that half.
  • Every new assertion was confirmed to fail against the previous implementation, restored in place;
    the nine pre-existing TestInternalPointer cases (including the #addr anchor refusal from compilers/openapi: an $anchor-resolved schema interns a malformed type ID #141)
    pass unchanged.
  • Full gate: gofmt, go vet, golangci-lint (0 issues), go build, and
    ./scripts/check-coverage.sh at 100% of 4224 statements. No golden snapshot moved — no corpus
    spec had used an encoded fragment, which is why the suite was green throughout.

A $ref is a URI, so its fragment may be percent-encoded: a component named
"Foo-Bar" can be addressed as `#/components/schemas/Foo%2DBar`. Reference
resolution split the ref by hand and compared the raw fragment text against
the declared component names, so every spec-correct escape missed. The
reference was reported as an error-severity unresolved-ref and the position
degraded to `any` — a silent type loss from a document the resolver had
resolved without complaint.

Read the fragment with the resolver's own accessors (references.Reference,
already a dependency of this package) instead of re-deriving the split here.
Agreement with the library that actually performs the resolution is the
property that matters: when the two disagree, the compiler either calls a
resolved reference unresolved, or mints an ID at a coordinate no source
position spells. nodeview.InternalPointer was already written against those
same two methods for the cycle scan; this brings the resolution side into
line, and removes the second hand-rolled copy that let them drift apart.

Two divergences beyond percent-decoding are fixed by the same move, since
they were artifacts of the hand-rolled split: a fragment with surrounding
whitespace, and one containing a second '#'. The document half is still
compared undecoded, matching the resolver, which trims it but does not
decode it.

Percent-encoded $dynamicRef fragments are unaffected and still fail to match
their $dynamicAnchor; that path is this compiler's own anchor index rather
than the library's resolver, and is filed separately as #233.

Closes #40
The first pass proved the reported symptom — a percent-encoded fragment
reported unresolved, the position degraded to `any` — and stopped there. Two
consequences of the same undecoded pointer were left unexercised, and both
are quieter than the one that was reported.

The pointer is an ID source as well as a resolution answer. An encoded
pointer through a component name reaches a position an unencoded pointer also
reaches, so before the fix the two interned separate nodes: one coordinate,
two types, both references resolving, no diagnostic on either side. irverify
has no reason to call the duplicate dangling, so nothing downstream saw it
either. Asserting it needs both spellings in one document — the encoded ref
alone lands on a single node whichever way the pointer reads, and only its
name is wrong, so a single-spelling fixture cannot see the duplicate at all.

The components that are not schemas are worse: their entries resolve through
the library, so the value arrived intact and no unresolved-ref was ever
emitted. Only the pointer stayed encoded, and every ID hoisted beneath it
inherited the encoding.

Discriminator mappings are the loudest and were also uncovered, though
InternalPointer's contract has always named them alongside $ref: an entry
whose target does not resolve is dropped, so an encoded target cost the union
a branch of its dispatch rather than merely degrading a type.

Each new assertion was confirmed to fail against the previous implementation
restored in place.
@OmarAlJarrah
OmarAlJarrah merged commit bf277f3 into main Aug 3, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-percent-encoded-ref branch August 3, 2026 01:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openapi: percent-encoded $ref fragments fail to resolve

1 participant