Skip to content

Advance past the whole delimiter rune in splitPath - #77

Open
youdie006 wants to merge 1 commit into
ajg:masterfrom
youdie006:splitpath-multibyte-delimiter
Open

Advance past the whole delimiter rune in splitPath#77
youdie006 wants to merge 1 commit into
ajg:masterfrom
youdie006:splitpath-multibyte-delimiter

Conversation

@youdie006

Copy link
Copy Markdown

splitPath (node.go:85) ranges over the path, so i is a byte index while r is a rune, but the delimiter branch returns path[i+1:]:

case !esc && r == d:
    return unescape(d, e, path[:i]), path[i+1:]

With a multi-byte delimiter the remaining continuation bytes get prepended to the next key segment. The encoder and decoder in this library then disagree with each other:

s, _ := EncodeToStringWith(&outer{inner{"x"}}, '\u00b7', '\\', false)
// s = "Foo%C2%B7Bar=x"
err := NewDecoder(strings.NewReader(s)).DelimitWith('\u00b7').Decode(&got)
// decode error: \ufffdBar doesn't exist in form.inner

splitPath('\u00b7', '\\', "Foo\u00b7Bar") returns rest = "\xb7Bar".

Everything else in the file is already rune-generic

That is what convinced me this is a bug rather than an undocumented ASCII-only restriction:

  • node.go:28 -- p + escape(d, e, k) + string(d)
  • node.go:44 -- strings.HasSuffix(k, string(d)+implicitKey)
  • node.go:52 -- strconv.Itoa(i) + string(d) + k
  • node.go:162,167 -- escape / unescape via strings.Replace(s, string(d), ...)

I checked those on unmodified master rather than assuming: escape('\u00b7','\\',"A\u00b7B") gives "A\\\u00b7B", unescape inverts it, and values produces the key Foo\u00b7Bar correctly. One line out of six does byte arithmetic.

Encoder.DelimitWith and Decoder.DelimitWith both take a rune, and neither the doc comments (decode.go:35, encode.go:34) nor the README restrict it to ASCII.

The fix

utf8.DecodeRuneInString for the width, standard library only. I picked it over utf8.RuneLen(r) deliberately: on an invalid byte, DecodeRuneInString returns (RuneError, 1), which is exactly what range consumed, whereas RuneLen(utf8.RuneError) is 3 and would over-advance on ill-formed input.

Compatibility note

Per CONTRIBUTING's stable-wire-format rule: for any single-byte delimiter size == 1, so the expression is byte-identical to master and nothing changes. The only inputs whose result changes are those that currently produce mangled keys or a decode error -- nothing that works today stops working. The full suite, including node_test.go's escapingTestCases, is green unmodified; no existing assertion had to change.

Verification

The three commands CONTRIBUTING asks for: gofmt -l . prints nothing, go vet ./... clean, go test ./... gives ok github.com/ajg/form and ok github.com/ajg/form/multipart. I also ran the fuzz target you mention -- go test -run='^$' -fuzz=FuzzDecodeString -fuzztime=30s, 768,851 execs, PASS.

Two tests added: a public-API round-trip over delimiters of 1, 2, 3 and 4 bytes, and a splitPath table that includes an escaped multi-byte delimiter. I checked the width from four directions rather than only confirming the new rows go green:

mutation result
revert to path[i+1:] fails on the 2, 3 and 4-byte rows
path[i+size+1:] fails, including the ASCII '|' row
path[i+size-1:] fails, including the ASCII row
hardcode size := 2 fails on the ASCII row and the 3-byte row

The last two matter most: they show the table pins the width exactly rather than merely rejecting the old behaviour, and that this is a width computation rather than a shifted offset.

What I did not change

  • escape, unescape, merge, parseValues -- already correct, verified on master.
  • The esc state machine for a multi-byte escape rune -- it does no byte arithmetic; the table row {'\u00b7', "Foo\\\u00b7Bar\u00b7Qux"} covers the escaped-multibyte-delimiter path.
  • I did not add validation rejecting multi-byte delimiters. That would be an API restriction and would need an issue first per CONTRIBUTING's scope section; making the decoder agree with the encoder does not.

Disclosure: AI-assisted. I found and prepared this with an AI assistant, and I ran and verified everything above myself.

splitPath ranges over the path, so i is a byte index while r is a rune,
but the delimiter branch returns path[i+1:]. With a multi-byte delimiter
the remaining continuation bytes are prepended to the next key segment,
so DelimitWith('.') style round-trips fail for any non-ASCII delimiter:

  EncodeToStringWith(v, '·', '\\', false)  ->  "Foo%C2%B7Bar=x"
  Decode of that  ->  "\ufffdBar doesn't exist"

escape, unescape, merge and the implicit-key handling all use string(d)
and are already rune-generic; this one line was doing byte arithmetic.

DecodeRuneInString returns (RuneError, 1) for an invalid byte, matching
what range consumed, so an ill-formed path advances exactly as before.
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