Skip to content

fix(expr): Escape control characters in repr_py and repr_python - #374

Merged
mwiebe merged 3 commits into
OpenJobDescription:mainfrom
leongdl:fix/repr-py-escape-control-chars
Sep 10, 2026
Merged

fix(expr): Escape control characters in repr_py and repr_python#374
mwiebe merged 3 commits into
OpenJobDescription:mainfrom
leongdl:fix/repr-py-escape-control-chars

Conversation

@leongdl

@leongdl leongdl commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

TLDR. repr_py escaped two characters where CPython escapes a table, so a value holding a
newline, a carriage return or a NUL came out raw inside the quotes and CPython refused to parse it.
ExprValue::repr_python had the same defect and escaped nothing at all, rendering a\b as a\x08.
Both now call one function. Verified byte-identical to CPython 3.14's repr for all 1,112,064
Unicode 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_py as following Python's
repr, with the worked example
repr_py("hello\nworld") returning 'hello\\nworld'.

write_repr_py_string escaped \ 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>b for every Unicode scalar value through the old code and fed each result to
ast.literal_eval, which is CPython's own parser. Of 1,112,064 values, three do not parse:

Code point Emitted CPython
U+000A 'a<LF>b' SyntaxError: unterminated string literal
U+000D 'a<CR>b' SyntaxError: unterminated string literal
U+0000 'a<NUL>b' SyntaxError: source code string cannot contain null bytes

Three 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 -c program is made of, and U+000D is what a
value picked up from a Windows-authored file carries. Both reach repr_py on the ordinary
template path: openjd-model passes user-supplied parameter values into FormatString::new with
no host context or extension needed.

A second site, worse. ExprValue::repr_python (value.rs:1240) built literals with
format!("ExprValue('{s}')"), escaping nothing. It reaches Python as ExprValue.__repr__, so it is
what a developer sees when printing a value:

Input Before Parses as
it's ExprValue('it's') SyntaxError
a\b ExprValue('a\b') a\x08, silent corruption
hello\nworld raw newline in the quotes SyntaxError

The a\b row is the one worth pausing on. A __repr__ whose whole job is to show what a value
is showed a different value and said nothing about it. specs/expr/public-api.md:749 commented
this 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. The
task body is a two-line python -c program, which is the conformance suite's own portable-fixture
convention — 150 of 1,191 merged fixtures use a multi-line args entry.

steps:
- name: Step1
  script:
    actions:
      onRun:
        command: python
        # A newline inside the arg. Legal today: openjd-model's ArgString check
        # rejects control characters except \n and \r.
        args: ["-c", "import sys\nprint('GRAND_CHILD_RAN')"]
environments:
- specificationVersion: environment-2023-09
  extensions: [WRAP_ACTIONS, EXPR]
  environment:
    name: WrapEnv
    script:
      actions:
        onWrapTaskRun:
          command: python
          args:
          - "-c"
          # repr_py emits the newline RAW here, so the generated program is
          # `subprocess.run(['python', '-c', 'import sys` and then a line break.
          - "import subprocess,sys; sys.exit(subprocess.run([{{repr_py(WrappedAction.Command)}}]+{{repr_py(WrappedAction.Args)}}).returncode)"

Before, the grandchild never runs:

File "<string>", line 1
  ...subprocess.run(['python', '-c', 'import sys
                                     ^
SyntaxError: unterminated string literal

Upstream fixtures, both parked as expected failures:
openjd-specifications#162
(WRAP_ACTIONS/jobs/proposed/wrap-repr-py-escapes-newline-in-wrapped-args) and
EXPR/jobs/proposed/expr2.2.6--repr-py-newline-roundtrip on branch
conformance-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 call
it.

Sharing it is the point rather than a tidiness gain. repr_py and repr_python render the same
literal 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 it
leaves 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. It
takes 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_literal is the
allocating form, for the format!-based callers in value.rs.

2. select_quote, why " is ever used. CPython prefers ', switching to " only to avoid
escaping an embedded '. A value holding both quote characters keeps ' and escapes it. That
third case is the one a reader gets wrong, and it is why the tests feed values containing both.

3. is_non_printable enumerates the PRINTABLE categories and negates. This looks backwards
and is deliberate. GeneralCategory is #[non_exhaustive], so a wildcard on the non-printable
side 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. \xNN up to U+00FF, \uNNNN
up to U+FFFF, \UNNNNNNNN above. The widest looks alarming at ten characters but needs four
input bytes, so the worst ratio is \x00's four-for-one, under escaped_bound's
MAX_ESCAPE_EXPANSION of 6. Measured across all 1.1M code points: zero exceed it.

5. repr.rs loses its local escaper. write_repr_py and write_repr_py_ref call the shared
writer. The RangeExpr arm is routed too, although RangeExpr's Display emits only digits and
-, :, ,, so nothing there can need escaping — routed so no future reader has to re-derive
that.

6. value.rs routes six arms. String, Path, float-with-preserved-spelling, RangeExpr, the
list-element arm, and the _ fallback. The float, RangeExpr and _ routings are inert, since no
reachable 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 is Unresolved, which renders
<unresolved[string]>. It is unreachable through evaluation: the evaluator returns an unresolved
result 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_py output changes for any value containing a control character, a non-printable non-ASCII
character, or a ' without a ". Every such output was previously wrong or unparseable, so no
correct output moves. ExprValue.__repr__ changes for the same values.

Nothing else moves. repr_json, repr_sh, repr_cmd, repr_pwsh, string() and
to_display_string() are byte-identical, verified by diffing 475 rendered values against
origin/main rather than by argument. repr_pwsh deliberately 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-python has no
Python-side expression evaluator — openjd/expr/__init__.py re-exports the whole language from
the PyO3 extension, which pins openjd-expr from crates.io. What the upstream fixtures call two
implementations 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 installs
openjd-cli from 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 pass
    unmodified. cargo clippy --all-features --all-targets --workspace -- -D warnings, nightly
    cargo fmt --all --check, cargo doc --no-deps --workspace with -D warnings,
    cargo +1.94.1 check --workspace (MSRV), cargo deny check, and a wasm32-unknown-unknown
    release build are all clean. EXPR conformance 369/369 against the release CLI.
  • CPython is the oracle, not my judgement. Every expected value in the new tests was read out
    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_eval on CPython
    3.11.12, 3.12.10, 3.13.7 and 3.14.0b4.
  • Full-range sweep. Against 3.14.0b4, whose Unicode 16.0 matches the crate's:
    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.
  • 60 new tests, mutation-checked. Independent reviewers each ran their own mutation set, 18 in
    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 ". And list[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.
  • Not verified: Windows and macOS CI matrix rows, the cross-user jobs, the vitest suite, and
    the full non-EXPR conformance corpus. The diff is platform-independent and touches no
    openjd-sessions code.

Was this change documented?

Yes, and the absence of documentation was part of the defect. specs/ said nothing about
repr_py's output, so nobody noticed the escape table was two entries long.

  • specs/expr/function-library.md gains the output contract: the round-trip guarantee, the
    delimiter rule, the escape table, the eight non-printable categories, the Unicode-version
    caveat, and why repr_pwsh stays separate.
  • specs/expr/public-api.md:749's false // matches Python repr comment is replaced with what
    repr_python actually promises.
  • specs/expr/architecture.md lists py_escape.rs.
  • Every function in the new module has a doc comment, including one for why the
    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 Cc characters in <ArgString>, which
excludes 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_escape is pub(crate). ExprValue::repr_python's output
changes for values it previously rendered unparseably, which is the fix rather than a break.

No new dependencies. The first revision added unicode-general-category; ea20772 removed it in
favour of the generated table, so the manifest and lock are back to where they started apart from
the 738-range NONPRINTABLE addition to unicode_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_MAP came out byte-identical to the 3.14.7 output, so the only content change is the
added 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 all
three produced SyntaxError rather than parsing. Python compiles a whole module before executing
any 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 fails
closed. 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.

@leongdl
leongdl requested a review from a team as a code owner September 9, 2026 06:16
Comment thread crates/openjd-expr/Cargo.toml Outdated
Comment thread specs/expr/function-library.md Outdated
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 seant-aws left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be breaking or its okay to consider just a fix, as this was assumed there

seant-aws
seant-aws previously approved these changes Sep 9, 2026
@leongdl

leongdl commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

should this be breaking or its okay to consider just a fix, as this was assumed there

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
leongdl force-pushed the fix/repr-py-escape-control-chars branch from ea20772 to 9bfb26b Compare September 10, 2026 00:22
Comment thread crates/openjd-expr/src/functions/repr.rs
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
leongdl force-pushed the fix/repr-py-escape-control-chars branch from 64b7668 to 4461e67 Compare September 10, 2026 05:07
@leongdl leongdl changed the title fix(expr): repr_py and repr_python escape control characters fix(expr): Escape control characters in repr_py and repr_python Sep 10, 2026
@mwiebe
mwiebe enabled auto-merge (squash) September 10, 2026 07:01
@mwiebe
mwiebe merged commit 5b04959 into OpenJobDescription:main Sep 10, 2026
22 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants