fix(expr): Escape control characters in repr_py and repr_python - #374
Merged
mwiebe merged 3 commits intoSep 10, 2026
Merged
Conversation
leongdl
added a commit
to leongdl/openjd-rs
that referenced
this pull request
Sep 9, 2026
Review on OpenJobDescription#374 pointed out that the crate already generates version-pinned Unicode tables for exactly this purpose, and that adding unicode-general-category put a second, independent Unicode data source in one crate. The two could drift with nothing in the build noticing: str.isalpha answering from Unicode 16.0.0 while repr_py answered from whatever the dependency happened to ship under a caret constraint. scripts/generate_unicode_tables.py now emits NONPRINTABLE, probed as `not chr(cp).isprintable()`, which is Py_UNICODE_ISPRINTABLE inverted. is_non_printable reads it through the existing in_table, and the dependency is gone from the workspace manifest, the crate manifest and the lock. Three things fall out. Parity is now exact rather than approximate, against the same CPython the rest of the crate targets, so the spec no longer frames the version caveat as inherent -- it was not. The #[non_exhaustive] GeneralCategory workaround disappears with the enum it was guarding, taking 23 lines of category enumeration with it. And there is no new license entry to reason about. The reviewer also asked for the pin to be asserted somewhere, since a caret dependency is free to move silently. the_pinned_unicode_version_has_not_moved asserts UNICODE_VERSION, so regenerating on a newer Unicode fails a test rather than quietly changing which code points repr_py escapes. Regenerated with CPython 3.14.0b4, which is the newest 3.14 this machine's uv index offers. All 13 pre-existing tables and the 1479-entry TITLE_MAP came out byte-identical to the committed 3.14.7 output, so the only content change is the added table; the provenance header moved from 3.14.7 to 3.14.0b4 and is worth regenerating on the release if a maintainer prefers that recorded. NONPRINTABLE is 738 ranges over 957,254 code points, the same set the dropped dependency produced. Output is unchanged, which is the point. Re-measured after the swap: 1,112,064 of 1,112,064 byte-identical to CPython 3.14's repr, zero round-trip failures, and 28 of 28 expectations exact on 3.11.12, 3.12.10, 3.13.7 and 3.14.0b4. 7517 tests pass, 0 fail. Also narrows the spec's parse guarantee to the string-valued arms, per the second review thread: repr_py of a float renders Float64's preserved spelling verbatim and unquoted, on a caller contract that with_str does not enforce. Left as a caller contract per maintainer guidance, with a pointer to OpenJobDescription#328, which addresses the root cause. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
seant-aws
reviewed
Sep 9, 2026
seant-aws
left a comment
Contributor
There was a problem hiding this comment.
should this be breaking or its okay to consider just a fix, as this was assumed there
seant-aws
previously approved these changes
Sep 9, 2026
Contributor
Author
This is OK since it did not pass a new proposed conformance test. This PR changes nothing to current test expectations. |
leongdl
added a commit
to leongdl/openjd-rs
that referenced
this pull request
Sep 10, 2026
Review on OpenJobDescription#374 pointed out that the crate already generates version-pinned Unicode tables for exactly this purpose, and that adding unicode-general-category put a second, independent Unicode data source in one crate. The two could drift with nothing in the build noticing: str.isalpha answering from Unicode 16.0.0 while repr_py answered from whatever the dependency happened to ship under a caret constraint. scripts/generate_unicode_tables.py now emits NONPRINTABLE, probed as `not chr(cp).isprintable()`, which is Py_UNICODE_ISPRINTABLE inverted. is_non_printable reads it through the existing in_table, and the dependency is gone from the workspace manifest, the crate manifest and the lock. Three things fall out. Parity is now exact rather than approximate, against the same CPython the rest of the crate targets, so the spec no longer frames the version caveat as inherent -- it was not. The #[non_exhaustive] GeneralCategory workaround disappears with the enum it was guarding, taking 23 lines of category enumeration with it. And there is no new license entry to reason about. The reviewer also asked for the pin to be asserted somewhere, since a caret dependency is free to move silently. the_pinned_unicode_version_has_not_moved asserts UNICODE_VERSION, so regenerating on a newer Unicode fails a test rather than quietly changing which code points repr_py escapes. Regenerated with CPython 3.14.0b4, which is the newest 3.14 this machine's uv index offers. All 13 pre-existing tables and the 1479-entry TITLE_MAP came out byte-identical to the committed 3.14.7 output, so the only content change is the added table; the provenance header moved from 3.14.7 to 3.14.0b4 and is worth regenerating on the release if a maintainer prefers that recorded. NONPRINTABLE is 738 ranges over 957,254 code points, the same set the dropped dependency produced. Output is unchanged, which is the point. Re-measured after the swap: 1,112,064 of 1,112,064 byte-identical to CPython 3.14's repr, zero round-trip failures, and 28 of 28 expectations exact on 3.11.12, 3.12.10, 3.13.7 and 3.14.0b4. 7517 tests pass, 0 fail. Also narrows the spec's parse guarantee to the string-valued arms, per the second review thread: repr_py of a float renders Float64's preserved spelling verbatim and unquoted, on a caller contract that with_str does not enforce. Left as a caller contract per maintainer guidance, with a pointer to OpenJobDescription#328, which addresses the root cause. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
leongdl
force-pushed
the
fix/repr-py-escape-control-chars
branch
from
September 10, 2026 00:22
ea20772 to
9bfb26b
Compare
leongdl
added a commit
to leongdl/openjd-rs
that referenced
this pull request
Sep 10, 2026
AGENTS.md asks for the report to be struck through in the same commit as the fix, and OpenJobDescription#374 did not do it. Review caught the omission. Defect 17 and probe X15 are closed and struck. Verified at this commit rather than assumed: repr_py('a\nb') renders 'a\nb', and \r, NUL, tab, ESC, DEL, C1 and non-printable non-ASCII all escape with no raw control character surviving. Recommendation 8 is marked Partially resolved, following the convention already used for items 1 and 10. Its repr_py clause is done; its repr_cmd clause is untouched, and the hazard is confirmed by measurement: a value that needs quotes and ends in a backslash emits a closing quote preceded by a backslash, which CommandLineToArgvW reads as an escaped quote. No Rust test covers it, and settling whether the fix belongs in the implementation or in spec 2.2.6 needs the subprocess round-trip the recommendation asks for, which no non-Windows host can run. Also corrects that note's reproducer in section 7. repr_cmd('C:\dir\') returns C:\dir\ UNQUOTED and is harmless, because the value holds no character from the quoting set. The hazard needs one, so the reproducer is repr_cmd('C:\my dir\') -> "C:\my dir\". Left open, with a reproducer that reproduces. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
repr_py escaped only backslash and quote, so a value carrying U+0000,
U+000A or U+000D emitted that character raw inside the literal. CPython
then refuses to parse the result: a raw newline or carriage return
terminates the string, and a NUL cannot appear in source at all. Any
template forwarding a multi-line value into generated Python broke,
including RFC 0008's own recommended wrap pattern
repr_py(WrappedAction.Args), and it failed as a SyntaxError naming a line
the template author never wrote.
Expression Language 2.2.6 defines repr_py as following Python's repr and
gives the worked example repr_py("hello\nworld") -> 'hello\\nworld'.
ExprValue::repr_python had the same defect and worse: it built literals
with format!("ExprValue('{s}')") and escaped nothing at all, not even the
quote or the backslash, so it rendered a\b as a\x08 -- silent corruption
in the one function whose job is to show what a value is.
Both now call py_escape::write_py_string_literal, a single implementation
of CPython's rule: delimiter selection, then the escape table, with the
printability test taken from the Unicode general category. Sharing it is
the point. Two renderers of the same literal had drifted to two different
wrong answers, and one function cannot disagree with itself.
is_non_printable enumerates the printable categories and negates, rather
than listing the non-printable ones. GeneralCategory is non_exhaustive, so
a wildcard on the other side would emit a future variant raw, which is the
defect class being closed here. An unrecognised category is escaped
instead: uglier output, never wrong output.
Measured against CPython 3.14.0b4, whose Unicode 16.0 matches the crate's:
byte-identical repr for all 1,112,064 Unicode scalar values, zero
mismatches. Against 3.11.12, 3.12.10 and 3.13.7 the only divergences are
code points those versions still call unassigned, 5185 of them on 3.13,
and every value round-trips through ast.literal_eval on all four. The
sweep runs through the shipped evaluator, not a prototype.
Adds unicode-general-category 1.1 (Apache-2.0, no transitive
dependencies, no_std, host-only build script) for the printability rule
above U+0080. Below it the answer is fixed for all time and is decided
arithmetically, without the tables.
Adds 60 tests. The 25 pre-existing repr tests pass unmodified, and the
change alters no output from repr_json, repr_sh, repr_cmd, repr_pwsh,
string() or to_display_string(), verified by diffing 475 rendered values
against origin/main. Every behaviour the change introduces was
mutation-tested: 18 mutations, all caught.
Design note, measurement harness and the CPython cross-check live in
SuperDaveDocs under docs/conformance-0907/repr_py.
Pinned upstream by openjd-specifications#162 and by
EXPR/jobs/proposed/expr2.2.6--repr-py-newline-roundtrip, both parked as
expected failures. Also recorded as openjd-rs security finding 22.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Review on OpenJobDescription#374 pointed out that the crate already generates version-pinned Unicode tables for exactly this purpose, and that adding unicode-general-category put a second, independent Unicode data source in one crate. The two could drift with nothing in the build noticing: str.isalpha answering from Unicode 16.0.0 while repr_py answered from whatever the dependency happened to ship under a caret constraint. scripts/generate_unicode_tables.py now emits NONPRINTABLE, probed as `not chr(cp).isprintable()`, which is Py_UNICODE_ISPRINTABLE inverted. is_non_printable reads it through the existing in_table, and the dependency is gone from the workspace manifest, the crate manifest and the lock. Three things fall out. Parity is now exact rather than approximate, against the same CPython the rest of the crate targets, so the spec no longer frames the version caveat as inherent -- it was not. The #[non_exhaustive] GeneralCategory workaround disappears with the enum it was guarding, taking 23 lines of category enumeration with it. And there is no new license entry to reason about. The reviewer also asked for the pin to be asserted somewhere, since a caret dependency is free to move silently. the_pinned_unicode_version_has_not_moved asserts UNICODE_VERSION, so regenerating on a newer Unicode fails a test rather than quietly changing which code points repr_py escapes. Regenerated with CPython 3.14.0b4, which is the newest 3.14 this machine's uv index offers. All 13 pre-existing tables and the 1479-entry TITLE_MAP came out byte-identical to the committed 3.14.7 output, so the only content change is the added table; the provenance header moved from 3.14.7 to 3.14.0b4 and is worth regenerating on the release if a maintainer prefers that recorded. NONPRINTABLE is 738 ranges over 957,254 code points, the same set the dropped dependency produced. Output is unchanged, which is the point. Re-measured after the swap: 1,112,064 of 1,112,064 byte-identical to CPython 3.14's repr, zero round-trip failures, and 28 of 28 expectations exact on 3.11.12, 3.12.10, 3.13.7 and 3.14.0b4. 7517 tests pass, 0 fail. Also narrows the spec's parse guarantee to the string-valued arms, per the second review thread: repr_py of a float renders Float64's preserved spelling verbatim and unquoted, on a caller contract that with_str does not enforce. Left as a caller contract per maintainer guidance, with a pointer to OpenJobDescription#328, which addresses the root cause. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
AGENTS.md asks for the report to be struck through in the same commit as the fix, and OpenJobDescription#374 did not do it. Review caught the omission. Defect 17 and probe X15 are closed and struck. Verified at this commit rather than assumed: repr_py('a\nb') renders 'a\nb', and \r, NUL, tab, ESC, DEL, C1 and non-printable non-ASCII all escape with no raw control character surviving. Recommendation 8 is marked Partially resolved, following the convention already used for items 1 and 10. Its repr_py clause is done; its repr_cmd clause is untouched, and the hazard is confirmed by measurement: a value that needs quotes and ends in a backslash emits a closing quote preceded by a backslash, which CommandLineToArgvW reads as an escaped quote. No Rust test covers it, and settling whether the fix belongs in the implementation or in spec 2.2.6 needs the subprocess round-trip the recommendation asks for, which no non-Windows host can run. Also corrects that note's reproducer in section 7. repr_cmd('C:\dir\') returns C:\dir\ UNQUOTED and is harmless, because the value holds no character from the quoting set. The hazard needs one, so the reproducer is repr_cmd('C:\my dir\') -> "C:\my dir\". Left open, with a reproducer that reproduces. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
leongdl
force-pushed
the
fix/repr-py-escape-control-chars
branch
from
September 10, 2026 05:07
64b7668 to
4461e67
Compare
mwiebe
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR.
repr_pyescaped two characters where CPython escapes a table, so a value holding anewline, a carriage return or a NUL came out raw inside the quotes and CPython refused to parse it.
ExprValue::repr_pythonhad the same defect and escaped nothing at all, renderinga\basa\x08.Both now call one function. Verified byte-identical to CPython 3.14's
reprfor all 1,112,064Unicode scalar values, and round-tripping on 3.11 through 3.14. One PR fixes Python too: the
Python expression language is this crate through PyO3, so it needs a release rather than a
second patch. 18 mutations, all caught.
What was the problem/requirement? (What/Why)
Expression Language §2.2.6 defines
repr_pyas following Python'srepr, with the worked examplerepr_py("hello\nworld")returning'hello\\nworld'.write_repr_py_stringescaped\and'and nothing else(
crates/openjd-expr/src/functions/repr.rs:359). That covers what stops a literal closing early.It omits what makes a literal invalid.
I rendered
a<CH>bfor every Unicode scalar value through the old code and fed each result toast.literal_eval, which is CPython's own parser. Of 1,112,064 values, three do not parse:'a<LF>b'SyntaxError: unterminated string literal'a<CR>b'SyntaxError: unterminated string literal'a<NUL>b'SyntaxError: source code string cannot contain null bytesThree causes, not three affected values. Any string holding one fails, so 7 of 28 realistic
payloads break. U+000A is what a multi-line
python -cprogram is made of, and U+000D is what avalue picked up from a Windows-authored file carries. Both reach
repr_pyon the ordinarytemplate path:
openjd-modelpasses user-supplied parameter values intoFormatString::newwithno host context or extension needed.
A second site, worse.
ExprValue::repr_python(value.rs:1240) built literals withformat!("ExprValue('{s}')"), escaping nothing. It reaches Python asExprValue.__repr__, so it iswhat a developer sees when printing a value:
it'sExprValue('it's')SyntaxErrora\bExprValue('a\b')a\x08, silent corruptionhello\nworldSyntaxErrorThe
a\brow is the one worth pausing on. A__repr__whose whole job is to show what a valueis showed a different value and said nothing about it.
specs/expr/public-api.md:749commentedthis function
// matches Python repr, which was false.Example template, and where it breaks
RFC 0008's reference wrap-forwarding pattern round-trips the wrapped action through
repr_py. Thetask body is a two-line
python -cprogram, which is the conformance suite's own portable-fixtureconvention — 150 of 1,191 merged fixtures use a multi-line
argsentry.Before, the grandchild never runs:
Upstream fixtures, both parked as expected failures:
openjd-specifications#162
(
WRAP_ACTIONS/jobs/proposed/wrap-repr-py-escapes-newline-in-wrapped-args) andEXPR/jobs/proposed/expr2.2.6--repr-py-newline-roundtripon branchconformance-func-lib-expected-failures. Also openjd-rs security finding 22.What was the solution? (How)
One function,
py_escape::write_py_string_literal, implementing CPython's rule. Both sites callit.
Sharing it is the point rather than a tidiness gain.
repr_pyandrepr_pythonrender the sameliteral and had drifted to two different wrong answers. One function cannot disagree with itself.
Fixing only
repr_py's escape table was the smaller change and is not what this does, because itleaves the second renderer wrong and leaves the next one free to be wrong again.
Walkthrough of the fix
Following the call path down, which is also the diff's order.
1.
write_py_string_literal, the one rule. Delimiter selection, then the escape table. Ittakes the whole literal including the quotes, not just the body, because the delimiter choice
depends on scanning the value — an escape routine taking the delimiter as a parameter would push
that scan onto every caller and let the two sites disagree again.
py_string_literalis theallocating form, for the
format!-based callers invalue.rs.2.
select_quote, why"is ever used. CPython prefers', switching to"only to avoidescaping an embedded
'. A value holding both quote characters keeps'and escapes it. Thatthird case is the one a reader gets wrong, and it is why the tests feed values containing both.
3.
is_non_printableenumerates the PRINTABLE categories and negates. This looks backwardsand is deliberate.
GeneralCategoryis#[non_exhaustive], so a wildcard on the non-printableside would treat a future variant as printable and emit it raw, which is the exact defect class
this module closes. This way an unrecognised category is escaped: uglier output, never wrong
output. Below U+0080 the answer is fixed for all time, so that range is decided arithmetically and
never consults the tables — 0.6 ns/char against 6.3, so an all-ASCII payload pays nothing.
4.
write_escaped_code_point, and why the budget did not move.\xNNup to U+00FF,\uNNNNup to U+FFFF,
\UNNNNNNNNabove. The widest looks alarming at ten characters but needs fourinput bytes, so the worst ratio is
\x00's four-for-one, underescaped_bound'sMAX_ESCAPE_EXPANSIONof 6. Measured across all 1.1M code points: zero exceed it.5.
repr.rsloses its local escaper.write_repr_pyandwrite_repr_py_refcall the sharedwriter. The
RangeExprarm is routed too, althoughRangeExpr'sDisplayemits only digits and-,:,,, so nothing there can need escaping — routed so no future reader has to re-derivethat.
6.
value.rsroutes six arms. String, Path, float-with-preserved-spelling,RangeExpr, thelist-element arm, and the
_fallback. The float,RangeExprand_routings are inert, since noreachable input differs. Routed anyway so the file has one rule rather than one rule and five
audits.
7. One arm deliberately not routed.
write_repr_py's_arm isUnresolved, which renders<unresolved[string]>. It is unreachable through evaluation: the evaluator returns an unresolvedresult for a call with an unresolved argument, so the renderer never sees one. I tried 13 routes to
refute that and could not. Quoting it would change output nothing produces and no test could reach,
so it stays exactly as it was, with a comment saying why.
What is the impact of this change?
repr_pyoutput changes for any value containing a control character, a non-printable non-ASCIIcharacter, or a
'without a". Every such output was previously wrong or unparseable, so nocorrect output moves.
ExprValue.__repr__changes for the same values.Nothing else moves.
repr_json,repr_sh,repr_cmd,repr_pwsh,string()andto_display_string()are byte-identical, verified by diffing 475 rendered values againstorigin/mainrather than by argument.repr_pwshdeliberately does not share the new module:PowerShell doubles
''and admits a raw newline.This PR fixes Python too, and that needs one more step.
openjd-model-for-pythonhas noPython-side expression evaluator —
openjd/expr/__init__.pyre-exports the whole language fromthe PyO3 extension, which pins
openjd-exprfrom crates.io. What the upstream fixtures call twoimplementations is one codebase seen twice, and Python picks the fix up from a release plus
cargo update -p openjd-expr, with no second patch to write. The Rust conformance lane installsopenjd-clifrom crates.io, so it will not see this until then.How was this change tested?
cargo test --workspace: 7516 passed, 0 failed. The 25 pre-existing repr tests passunmodified.
cargo clippy --all-features --all-targets --workspace -- -D warnings, nightlycargo fmt --all --check,cargo doc --no-deps --workspacewith-D warnings,cargo +1.94.1 check --workspace(MSRV),cargo deny check, and awasm32-unknown-unknownrelease build are all clean. EXPR conformance 369/369 against the release CLI.
of CPython rather than derived from the rules. A harness drives the shipped evaluator and
compares: 28/28 byte-exact and 28/28 round-tripping through
ast.literal_evalon CPython3.11.12, 3.12.10, 3.13.7 and 3.14.0b4.
1,112,064 / 1,112,064 byte-identical, zero mismatches. Against 3.13.7 the 5,185 divergences
are all code points that version still calls unassigned, and round-trip holds at every version.
That drift is one-directional and cannot break the parse contract, so the property the tests
pin is round-trip rather than bytes.
total, across the escape table, the delimiter rule, each category, the escape
widths, the ASCII fast path and all six call sites. All 18 caught. The reviewers found two real
defects, both fixed here. One assertion was vacuous, because a single-character value can never
contain its own delimiter: a value holding only
'flips the delimiter to". Andlist[path]plus nested-list recursion were unprotected on both sites. I also deleted four tests as
duplicates of unit-level facts, then re-verified the call-site mutations as still caught.
the full non-EXPR conformance corpus. The diff is platform-independent and touches no
openjd-sessionscode.Was this change documented?
Yes, and the absence of documentation was part of the defect.
specs/said nothing aboutrepr_py's output, so nobody noticed the escape table was two entries long.specs/expr/function-library.mdgains the output contract: the round-trip guarantee, thedelimiter rule, the escape table, the eight non-printable categories, the Unicode-version
caveat, and why
repr_pwshstays separate.specs/expr/public-api.md:749's false// matches Python reprcomment is replaced with whatrepr_pythonactually promises.specs/expr/architecture.mdlistspy_escape.rs.printable-category enumeration is inverted.
Worth raising upstream separately: Expression Language §2.2.6 should say that non-ASCII parity
depends on the implementation's Unicode version, since no two implementations can agree on an
unassigned code point. And Template Schemas §5.2 forbids
Cccharacters in<ArgString>, whichexcludes the newlines 150 merged fixtures rely on — a spec conflict that gates promoting #162's
fixture, though not this fix.
Is this a breaking change?
No public signature changes, and
py_escapeispub(crate).ExprValue::repr_python's outputchanges for values it previously rendered unparseably, which is the fix rather than a break.
No new dependencies. The first revision added
unicode-general-category;ea20772removed it infavour of the generated table, so the manifest and lock are back to where they started apart from
the 738-range
NONPRINTABLEaddition tounicode_tables.rs.Regenerating did move the table module's header from CPython 3.14.7 to 3.14.0b4, the newest 3.14
available on the machine that ran it. All 13 pre-existing tables and the 1479-entry
TITLE_MAPcame out byte-identical to the 3.14.7 output, so the only content change is theadded table. Worth regenerating on 3.14.7 if a maintainer prefers the release recorded.
Does this change impact security?
It closes a correctness defect and is not an injection fix — worth stating positively, since
"unescaped control characters in a quoting function" reads worse than it is. The old code escaped
'and\, so a literal could never be closed early; three crafted payloads were tried and allthree produced
SyntaxErrorrather than parsing. Python compiles a whole module before executingany of it, so a raw newline broke generation loudly instead of executing injected statements.
The new code strictly widens what is escaped, and the
#[non_exhaustive]handling in step 3 failsclosed. No file, directory or permission behaviour changes.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.