Skip to content

Reject non-canonical trailing-dot tree paths in resolveChildPath#76

Merged
linkdata merged 1 commit into
mainfrom
fix/jawstree-canonical-child-path
Jun 18, 2026
Merged

Reject non-canonical trailing-dot tree paths in resolveChildPath#76
linkdata merged 1 commit into
mainfrom
fix/jawstree-canonical-child-path

Conversation

@linkdata

Copy link
Copy Markdown
Owner

Defect (per-package code review)

A crafted WebSocket frame children.0..selected=true resolved to a valid in-range tree node when it should have been rejected. JawsSetPath's CutSuffix(".selected") left nodePath = children.0., and resolveChildPath consumed the children/0 pair in one loop iteration and exited with the empty trailing segment unvisited — returning Children[0] with no error.

The server then mutated that node while JawsPathSet broadcast the non-canonical children.0. verbatim as the jawstreeSetPath id. No peer's rendered node uses that id (Node.Walk emits the canonical children.0), so the visual selection was silently dropped on every peer, diverging their rendered tree from server state with no error surfaced. This is the peer-divergence class the earlier per-index canonical check was meant to close, but that check only inspects index segments and never sees an empty final segment.

Reachable only via a hand-crafted frame (the trusted client always builds canonical paths), but hardening against crafted client input is the documented contract of this path ("server holds the truth").

Fix

Reject a consumed-but-empty trailing segment as well: strings.Cut reports via its third return value whether a separator was consumed, so an index followed by . with nothing after it (more && rest == "") is now rejected. This closes the trailing-dot gap while keeping the cheap, allocation-free per-index canonical check.

How the implementation was chosen (benchmarked)

Three candidates were benchmarked (benchstat -col /impl, count=10): this incremental check, a canonical round-trip via strings.Builder, and a regexp validator. The incremental check was fastest and the only one allocation-free on accepted paths:

case incremental manual round-trip regex
shallow 10.1 ns, 0 allocs 20.6 ns, 1 alloc 41.0 ns, 0 allocs
deep 31.9 ns, 0 allocs 55.8 ns, 2 allocs 145.9 ns, 0 allocs

BenchmarkResolveChildPath is kept as a regression guard. Before/after vs main on accepted paths: unchanged and zero-alloc (+3–7 ns from one bool check); the only added cost is on rejected adversarial input, where an ErrPathRejected error is now correctly built instead of the path being wrongly accepted.

Tests

  • New rejection cases in TestNode_JawsSetPath_Gate (children.0..selected, .children.0.selected, children..0.selected) assert ErrPathRejected and that no node's Selected flips. The regression case (children.0..selected) fails on the unpatched code.
  • New positive cases assert canonical and nested paths still succeed.

Verification

go vet, gofumpt -l, staticcheck, go build, go test -race ./..., and go test -tags debug -race ./... all pass.

A crafted WebSocket frame `children.0..selected=true` resolved to a valid
in-range node: JawsSetPath's CutSuffix(".selected") left nodePath "children.0.",
and resolveChildPath consumed "children"/"0" in one iteration and exited with the
trailing empty segment unvisited, returning Children[0] with no error. The server
then mutated the node while JawsPathSet broadcast the non-canonical "children.0."
verbatim as the jawstreeSetPath id. No peer's rendered node uses that id (Node.Walk
emits the canonical "children.0"), so the visual selection was silently dropped on
every peer, diverging their rendered tree from server state with no error.

The per-index canonical check added earlier only inspects index segments and never
sees an empty final segment, so it could not catch this. Reject a consumed-but-empty
trailing segment as well (Cut reports the separator via its third return value),
which closes the trailing-dot gap while keeping the cheap alloc-free per-index check.

Add the failing rejection cases (and canonical-path regressions) to the gate test
table, plus BenchmarkResolveChildPath as a regression guard. The resolver was
chosen by benchmarking three candidates (this incremental check vs a canonical
round-trip via strings.Builder vs a regex validator); the incremental check was
fastest and the only one that stays allocation-free on accepted paths. Accepted
paths are unchanged from before (zero allocations, within a few ns); the added cost
is only on rejected adversarial input, where an error is now correctly returned.
@linkdata linkdata merged commit 40b9c2c into main Jun 18, 2026
6 checks passed
@linkdata linkdata deleted the fix/jawstree-canonical-child-path branch June 18, 2026 03:14
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.

1 participant