Repository navigation
parser: keep zero-width and non-breaking runes in directive values - #17809
vineethsaivs wants to merge 2 commits into
Conversation
This changes Ollama to stop silently rewriting Modelfile values that contain Unicode format or non-ASCII space characters. The buffer-append guard in ParseFile tested strconv.IsPrint. Its real job is to discard the rune 0 that parseRuneForState returns for a delimiter it has already consumed, but IsPrint is also false for every format rune and every non-ASCII space, so those were dropped from the value. The result is a model created with a system prompt or template the user never wrote, with no warning: SYSTEM میخواهم stored as میخواهم (ZWNJ removed, a different word) SYSTEM 👨👩👧 stored as 👨👩👧 (ZWJ removed, three separate emoji) SYSTEM a b stored as ab (non-breaking space removed) It was not a deliberate strip-the-invisibles policy either: U+FE0F is IsPrint true and survives today, so the same class of character is treated both ways. Testing !unicode.IsControl instead drops exactly the runes the guard is for, including the rune 0 sentinel, and leaves content alone. Newlines and tabs are unaffected: they are control runes, so they were dropped here before and still are, and multi-line values get them from the stateValue branch above.
HDPark95
left a comment
There was a problem hiding this comment.
Verified at bcddd84: five new cases are load-bearing (reverting parser.go fails them), but variation_selector passes on main too, as U+FE0F is already strconv.IsPrint.
Untested regression: the guard runs in every state, and stateNil admits any rune into stateName, so an invisible rune starting a directive line now fails the whole file where main ignored it.
FROM m
<U+00A0>SYSTEM hi -> command must be one of ... (line 2)
Same for U+200B, U+200C, U+3000, U+2009 and a mid-file U+FEFF. Gating the new rule on curr == stateValue restored all of them, tests still green.
The first version applied the new rule in every state. stateNil admits any rune into stateName, so an invisible rune leading a directive line became part of the command name and failed the whole file, where main had ignored it. Gate the relaxed rule on stateValue and leave strconv.IsPrint everywhere else, which restores that behaviour while keeping the fix for values. Adds TestParseFileInvisibleRuneBeforeCommand covering the six leading runes reported in review; all six fail against the previous version of this branch.
|
You are right on both counts, and the regression is a real one I had missed. Fixed in The regression. I reproduced all six of your cases against the branch and they failed exactly as you described, while The cause is what you said: the guard runs in every state, so with Your suggested gate is what I took. The rule is now relaxed only when Added On the variation selector, also correct:
|
What this fixes
ollama createsilently rewrites anySYSTEM,TEMPLATE,MESSAGEorLICENSEvalue that contains a Unicode format character or a non-ASCII space. The model is created with a prompt the user never wrote, and nothing is logged.The zero-width non-joiner case is the one that worries me most: in Persian, Arabic and Hindi it is an ordinary letter-level character, not decoration, so removing it changes the word.
Why it happens
The guard that appends a rune to the value buffer tests
strconv.IsPrint. That guard exists to discard the rune0thatparseRuneForStatereturns for a delimiter it has already consumed. ButIsPrintis also false for every format rune (ZWJ, ZWNJ, RLM, soft hyphen) and every non-ASCII space (NBSP), so those get dropped from the value as well.It is not a deliberate "strip invisible characters" policy.
U+FE0F(variation selector 16) isIsPrinttrue and survives today, so two characters of the same class are treated differently.grep -rn IsPrint --include='*.go' .finds this single line, with no comment, test or documentation describing the stripping as intended.The change
Test
!unicode.IsControl(r)instead. That drops exactly what the guard is for and leaves content alone:IsPrint(before)IsControl(after)\n,\t,\rU+0007U+200CU+200DU+00A0U+200FU+00ADU+FE0FThe rune
0sentinel is itself a control rune, so it is still dropped without needing a separate check. Newlines and tabs are unaffected: they were dropped by this guard before and still are, and multi-line values receive them from thestateValuebranch above, not from here.The stdlib
unicodeimport needs an alias because the file already importsgolang.org/x/text/encoding/unicode.strconvhad no other use in the file, so its import goes.Tests
TestParseFileZeroWidthAndNonBreakingRunes, table-driven next to the existingTestParseMultiByte, covering ZWNJ, ZWJ, NBSP, RLM, soft hyphen and the variation selector, plus a case pinning that control characters are still dropped so the change stays scoped.Before the change, 5 of the 7 subtests fail. The two that pass either way are the deliberate controls: the variation selector already worked, and control characters must keep being dropped.
Note on overlap
I have another open PR, #17734, that touches
parser.goin a different place (the CRLF line counting near the top ofParseFile). They do not overlap inparser.go, but both append toparser_test.go, so whichever lands second may want a trivial rebase there. Happy to do that whenever.