FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(sessions): Escape strings in the sessions __repr__ output by leongdl · Pull Request #361 · OpenJobDescription/openjd-model-for-python · GitHub

fix(sessions): Escape strings in the sessions __repr__ output - #361

Open
leongdl wants to merge 4 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/repr-escaping-sessions
Open

fix(sessions): Escape strings in the sessions __repr__ output#361
leongdl wants to merge 4 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/repr-escaping-sessions

Conversation

leongdl commented Sep 10, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

TL;DR

__repr__ in rust-bindings/src/sessions/ was built with format!("{:?}"), which is not a Python literal writer. Rust renders a non-printable non-ASCII character as \u{a0}; Python requires exactly four hex digits after \u, so the repr does not parse. A job whose stdout contains a no-break space is enough to produce one. Every string field now goes through CPython's own repr(), so the escaping is by construction whatever the running interpreter produces.

6 sites, 176 new tests, 5 independent mutants. Rebased onto efba36c (0.11.11).

Review round added: a PathMappingRule repr that corrupted Windows paths silently, a panic path in the new helper, a mutex held across a CPython call, a Session keyword that did not match its constructor, and ESC coverage — see Review round.

Reproduction from a job template

This is a valid, unremarkable 2023-09 template. Nothing here is adversarial — the action prints a no-break space, the kind of thing that arrives from a localised DCC message, a copied filename, or a progress line.

specificationVersion: jobtemplate-2023-09
name: ReprEscapingDemo
steps:
- name: PrintNonAscii
  script:
    actions:
      onRun:
        command: python
        args:
        - -c
        - print("render complete\u00a0100%")

A consumer runs the action, captures stdout, and wraps it in an ActionResult — then logs it:

result = ActionResult(state=ActionState.SUCCESS, exit_code=0, stdout=captured)
repr(result)

Measured, running the template's action for real and reverting only ActionResult::__repr__:

repr(result) compile(r, "<repr>", "eval")
before ActionResult(state=SUCCESS, exit_code=Some(0), stdout="render complete\u{a0}100%\n") SyntaxError: truncated \uXXXX escape
after ActionResult(state=ActionState.SUCCESS, exit_code=0, stdout='render complete\xa0100%\n') OK, and eval(repr(x)) == x

The captured stdout is identical in both runs ('render complete\xa0100%\n'). Only the repr differs.

Why it fails — the call stack

  logging / f-string / pytest assertion
    └─ repr(action_result)                     Python
        └─ tp_repr slot                        CPython, dispatched by PyO3
            └─ PyActionResult::__repr__        rust-bindings/src/sessions/types.rs:528
                └─ format!("... stdout={:?}", self.stdout)
                    └─ <str as Debug>::fmt     Rust std
                        └─ char::escape_debug_ext per char
                            └─ non-printable  ⇒ writes  \u{a0}      ← Rust syntax
        ⇒ String handed back to CPython as the object's repr
            └─ anything parsing it: ast.literal_eval / eval / doctest / a
               reader copying it back into code
                └─ Python's \u escape wants exactly 4 hex digits, finds '{'
                    ⇒ SyntaxError: truncated \uXXXX escape

The divergence is narrow, which is why it survived. Rust's Debug for str special-cases only the quote, the backslash, and NUL/tab/CR/LF — those come out Python-legal, NUL as \0 which Python reads as octal. Every other control falls through to the brace form, including ESC. Measured with a standalone Rust binary:

input Rust {:?} valid Python?
a\nb "a\nb" yes
a"b "a\"b" yes
a\\b "a\\b" yes
a\0b "a\0b" yes (octal)
café "café" yes (printable)
a\xa0b "a\u{a0}b" no
a\x1bb "a\u{1b}b" no
a\x7fb "a\u{7f}b" no
a\u3000b "a\u{3000}b" no

ESC is the one that matters most in practice: ActionResult.stdout is captured process output, and ANSI colour sequences are ordinary content there — almost certainly the highest-frequency trigger in the field. Every quick check with a newline or a quote passes, which is why this survived.

Two further defects lived in the same format! expressions, and are fixed with it — leaving them would have kept the repr un-evaluable and cost the round-trip check that pins the escaping:

  • Debug for Option emitted exit_code=Some(0) → NameError: name 'Some' is not defined.
  • The state field rendered as a bare SUCCESS → also a NameError. It now uses the spelling the enum's own repr already produces, ActionState.SUCCESS.

Why the fix lives at the PyO3 layer and not in openjd-rs

Short version: __repr__ is a Python protocol and Debug is a Rust one. They are different contracts that happen to look alike. For these types there is also nothing downstream to fix, and pushing the fix down would mean reimplementing CPython's escaping in a crate that has no interpreter — when the interpreter is right there at the boundary.

  Python:  repr(action_result)
    │
    │  ── PyO3 / binding layer ─────────────────────────────────────
    ├─ PyActionResult::__repr__          rust-bindings/src/sessions/types.rs
    │     struct PyActionResult { state, exit_code, stdout }   ← defined HERE
    │     emits: "ActionResult(state=..., exit_code=..., stdout=...)"
    │              └─ Python constructor keywords, from #[new]'s signature
    │
    ├─ py_repr::py_str(py, s)            rust-bindings/src/py_repr.rs   ← THE FIX
    │     └─ PyString::new(py, s).repr()
    │          └─ CPython's own repr        needs Python<'_>; exists only here
    │
    │  ── openjd-rs (no interpreter, no Python concepts) ───────────
    └─ openjd_sessions::ActionResult      openjd-sessions-0.5.7/src/action.rs:74
          pub struct ActionResult { state, exit_code, stdout }
          #[derive(Debug)]  →  "a\u{a0}b"   ← correct Rust, round-trips in Rust

Three consequences of that shape:

For ActionResult there is no downstream to fix. PyActionResult is its own struct with its own fields, not a newtype over openjd_sessions::ActionResult. The object being repr'd does not exist in openjd-rs. And openjd_sessions::ActionResult's derived Debug is not wrong — "a\u{a0}b" is a legal Rust literal that round-trips in Rust, so a Rust consumer is served correctly today and this PR changes nothing for it.

The string is Python-specific even where a wrapper does exist. PathMappingRule(source_path_format=PathFormat.POSIX, source_path=...) names the Python constructor's keyword arguments and the Python enum's spelling. openjd-rs has no PathFormat.POSIX and no keyword arguments; teaching it those would be teaching a Rust crate about a Python API it does not have.

Pushing down means reimplementing, not moving. CPython's repr() needs a Python<'_> token, which exists only above the FFI boundary. Without one you must hand-roll the escaping plus a unicode printability table that tracks the CPython version — which is exactly what openjd-expr/src/py_escape.rs is, 465 lines. A reimplementation can drift from the interpreter; the interpreter's own repr cannot.

That also answers the suggestion on #359 of routing through the writer openjd-rs#374 added. Beyond the layering argument, it is not reachable from this crate:

openjd-expr-0.7.0/src/lib.rs:27:      pub(crate) mod py_escape;
openjd-expr-0.7.0/src/py_escape.rs:33: pub(crate) fn write_py_string_literal(...)

SymbolTable.__repr__ already delegates to Python's repr, so the pattern is established in this crate.

Where openjd-rs is the right layer

The inverse case exists, and it is why #374 lives upstream rather than here. repr_py is a template-callable function whose output is fixed by Expression Language §2.2.6: a template rendered by the Rust openjd CLI must produce byte-identical output to one rendered through Python. That contract spans both runtimes, so it cannot live in a Python binding — and because the CLI has no interpreter, it has to be reimplemented. The 465 lines are a cost worth paying there and not here.

The rule that separates the two: spec-mandated Python output belongs in Rust and must be reimplemented; Python-protocol output belongs at the binding and should delegate to the interpreter.

The cost of that split, stated plainly

It leaves two escaping implementations in one package: py_escape behind ExprValue.__repr__ and repr_py, and CPython's real repr() behind the sessions reprs. If py_escape ever diverged from CPython, those two families of repr would disagree. They agree today — ExprValue's repr was checked against CPython's on 31 inputs during the #359 bump and all matched — but nothing currently tests the two implementations against each other, so this is knowledge rather than a guarantee.

Sites

Site Change
ActionResult stdout escaped, Option, enum spelling
ActionStatus Option, enum spelling
PosixSessionUser user + group escaped
WindowsSessionUser user escaped
Session session_id escaped, session_id= keyword, enum spelling, lock released before the CPython call
PathMappingRule source_path + destination_path escaped (was hand-rolled '{}')

SessionState.__repr__ and ScriptRunnerState.__repr__ were already correct and are untouched.

Reachability

ActionResult.stdout is populated by the consumer, not by this binding — SessionConfig.debug_collect_stdout is hardcoded false here, and the binding never constructs a PyActionResult itself. The type is public and documented for exactly this use ("user code can also construct one directly"), and a worker agent that captures output and builds one hits the defect with any non-ASCII byte in a job's stdout.

PosixSessionUser carries a user name supplied by the caller. Worth noting for the threat model: template validation rejects only Cc control characters, so U+00A0 and U+3000 — both Zs — pass through step and job names untouched.

Tests

Adds test/openjd/sessions/, which did not exist; the sessions surface had no Python tests beyond a module-name check.

176 cases over 19 inputs: the characters Rust renders as \u{...}, the ones Debug does escape correctly (kept as controls against a hand-rolled replacement getting them wrong), printable non-ASCII that must survive verbatim, and negative controls for text needing no escaping. group is asserted separately from user because a fix applied to only the first argument would pass every user case.

Mutation-checked with each behaviour reverted independently against a green 113-case baseline. Each mutant tree was confirmed to build and import first, so no verdict is a disguised compile error:

Mutant Result
py_str → Rust {:?} 67 failed
py_opt_int → {:?} 44 failed
ActionState.X → bare X 43 failed
PathMappingRule py_str → '{}' 21 failed
Session session_id= → id= 19 failed

Verification

  • cargo build, cargo fmt --check, cargo clippy --all-targets — clean
  • Full suite: 5971 passed, 24 skipped, 3 xfailed, coverage 94.14% against the 94% gate. The 3 xfails are pre-existing and unrelated.
  • black --check, ruff check, mypy — clean
  • CI on the previous revision: 30/30 checks green, all six Windows Python jobs passing. The Windows run confirmed the posix gating works — skips went 1 → 50 with passed unchanged at 5882, i.e. exactly the 49 previously-failing tests, nothing dropped from collection.
  • No stub regeneration needed: a Python<'_> token is invisible to Python and PyResult<String> still maps to str. SymbolTable.__repr__ already uses this form and its stub entry is def __repr__(self) -> builtins.str.

Unverified: WindowsSessionUser's escaping. Off the process user its constructor demands a password or a logon token, so it cannot be built with an arbitrary name just to read its repr. It shares the helper the other three string sites test. I did not add a conditionally-skipping test that would pass everywhere while asserting nothing.

Scope

sessions/ only. The same patterns exist at ~13 sites under rust-bindings/src/model/ and 2 under rust-bindings/src/expr/ — including the FormatString.__repr__ originally flagged on #359, which escapes nothing at all, not even the quote. Those are follow-ups reusing the py_repr helper this PR introduces. Independent of #359; rebased onto mainline at the 0.11.11 release.

Review round

Five defects found by review on the first revision, each measured before fixing. Two were introduced by this PR's own fix, which is the part worth noting — routing a repr through CPython changes what can run while a lock is held, and the helper became a new single point of failure.

PathMappingRule corrupted Windows paths silently (expr/path_mapping.rs). Not a {:?} at all — it hand-rolled '{}' with no escaping, so it was invisible to the grep that found the others. Measured:

input repr result
destination_path='C:\temp' parses yields C: + TAB + emp — silent corruption
source_path="/home/o'brien" SyntaxError: unterminated string literal

This is the only repr found that corrupts on its most typical input — a Windows destination path is what path mapping exists to produce. Everything else in this bug class fails loudly at compile(). Pulled into this PR ahead of the model/ queue for that reason.

py_repr::py_str could panic instead of raising. .to_string() on a Bound<PyString> resolves to PyO3's Display, which calls str() and has nowhere to put a PyErr — ToString treats the formatter error as unreachable and panics. An unwind out of __repr__ across the FFI boundary, in the function every repr routes through, on a path reached mostly from logging and exception formatting. Now to_cow()?, which reads the UTF-8 directly and propagates.

Session.__repr__ held the snapshot mutex across the CPython call. New with this fix: the old format!("{:?}") touched no interpreter state. py_str allocates, allocation can trigger a GC pass, and a finalizer run by that pass can re-enter this Session and re-lock a non-reentrant Mutex. lock_recover maps a poisoned lock to into_inner(), so that failure would be silent. Fixed by reading out from under the guard and dropping it first; recorded in the helper's docs as a rule for the model/ follow-up.

Session.__repr__ used id=, not session_id=. It parsed and then raised TypeError: got an unexpected keyword argument 'id' — moving the failure from compile time to call time, which is worse than what it replaced.

ESC was missing from the test inputs, and the docs implied only non-ASCII was affected. Added ESC, U+0001 and U+001F. "a\x00b" was also mislabelled as representative of C0 when it passes only because NUL has its own arm.

Two claims of mine that were wrong, now corrected:

  • A test comment said ActionStatus has no __eq__. It does (types.rs:337), comparing seven fields; the repr emits two, so it is evaluable but does not round-trip — and cannot be made to, since started_at/ended_at are not constructor arguments. The misleading eval() is replaced by test_repr_omits_fields_that_eq_compares, which pins the lossiness.
  • py_repr's docs said delegating "retires the bug class". It retires it for sessions/ only. Wording narrowed and the remaining surface named.

Coverage added for Session and PathMappingRule, which the first revision omitted — both are constructible in a unit test, so those were oversights rather than constraints. Session skips the NUL case: the id becomes a working-directory path component and the filesystem rejects it before any repr is taken.

Evidence the PathMappingRule change is a no-op for well-behaved input: the pre-existing TestPathMappingRuleRepr in test/openjd/expr/test_path_mapping.py passes unmodified.

leongdl requested a review from a team as a code owner September 10, 2026 22:17
Comment thread test/openjd/sessions/test_repr.py Outdated
Comment thread rust-bindings/src/sessions/session.rs Outdated
Comment thread rust-bindings/src/sessions/session.rs Outdated
The sessions reprs were built with `format!("{:?}")`, which is not a
Python literal writer. Rust's `Debug` for `str` agrees with Python on the
quote, the backslash and the C0 controls, but renders anything else
non-printable as `\u{a0}`, and CPython wants exactly four hex digits after
`\u`. The literal then does not parse at all:

    >>> repr(ActionResult(state=ActionState.SUCCESS, exit_code=0,
    ...                   stdout="a\xa0b"))
    'ActionResult(state=SUCCESS, exit_code=Some(0), stdout="a\\u{a0}b")'
    SyntaxError: truncated \uXXXX escape

That made a repr corruptible by its own data. `ActionResult.stdout` is
captured process output, so a non-ASCII byte in a job's stdout is ordinary
rather than adversarial, and `PosixSessionUser` carries a user name that
arrives from outside. Both reach log lines and exception messages.

Every string field now goes through CPython's own `repr()` via a new
`py_repr` module. Delegating retires the bug class instead of
reimplementing Python's escaping table: the output is by construction
whatever the running interpreter produces, including its per-string choice
of quote character. openjd-rs#374 added an escaping writer upstream for
`ExprValue`, but it is `pub(crate)` in openjd-expr and so unreachable from
this crate; PyO3 exposes the interpreter's `repr()` directly, which needs
no upstream change and is the same oracle the tests assert against.

Two neighbouring defects in the same expressions went with it, because
leaving them would have kept the repr un-evaluable and so left the fix
unverifiable by round-trip:

* `Debug` for `Option` emitted `exit_code=Some(0)`, a `NameError`.
* The state field rendered as a bare `Success` / `SUCCESS`, also a
  `NameError`. It now uses the spelling the enum's own repr already
  produces, `ActionState.SUCCESS`.

`eval(repr(x)) == x` now holds for `ActionResult` across all 16 test
inputs, which is what pins the escaping rather than a spelling assertion
alone.

Four sites changed: `ActionResult`, `ActionStatus`, `PosixSessionUser`,
`WindowsSessionUser` and `Session`. `SessionState.__repr__` and
`ScriptRunnerState.__repr__` were already correct.

Adds test/openjd/sessions/, which did not exist -- the sessions surface
had no Python tests beyond a module-name check. 113 cases over 16 inputs:
the characters Rust renders as `\u{...}`, the ones it escapes correctly,
printable non-ASCII that must survive verbatim, and negative controls for
text needing no escaping. `group` is asserted separately from `user`
because a fix applied to only the first argument would pass every `user`
case.

Mutation-checked, each behaviour reverted independently against a green
113-case baseline: escaping 67 failed, Option 44 failed, enum spelling 43
failed.

No stub regeneration: a `Python<'_>` token is invisible to Python and
`PyResult<String>` still maps to `str`, so the emitted signature is
unchanged. `SymbolTable.__repr__` already uses this form and its stub
entry is `def __repr__(self) -> builtins.str`.

WindowsSessionUser is unverified -- it cannot be constructed off Windows
(`RuntimeError: Only available on Windows systems`), so its change is
by inspection and shares the helper the other three sites test.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The binding gates construction on `#[cfg(unix)]` and raises
`RuntimeError: Only available on posix systems.` elsewhere, so the four
tests that build one failed on the Windows matrix: 49 failures, exactly
the 16+16+16+1 cases those tests parametrize.

Mirrors the Rust guard with `os.name`. WindowsSessionUser gets no
counterpart: off the process user it demands a password or a logon token,
so it cannot be built with an arbitrary name just to read its repr.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
leongdl force-pushed the fix/repr-escaping-sessions branch from daf2fa5 to 4a464df Compare September 11, 2026 00:20
Comment thread rust-bindings/src/py_repr.rs Outdated
Five defects from review, each measured before fixing.

**py_repr::py_str could panic instead of raising.** `.to_string()` on a
`Bound<PyString>` resolves to PyO3's `Display`, which calls `str()` on the
object and has nowhere to put a `PyErr` -- `ToString` treats the formatter
error as unreachable and panics. That put an unwind across the FFI boundary
in the one function four reprs route through, and reprs are evaluated from
logging and exception formatting where an unwind loses the diagnostic that
prompted it. Now uses `to_cow()?`, which reads the UTF-8 directly,
propagates failure, and skips a redundant interpreter round-trip.

**Session's repr held the snapshot mutex across a CPython call.** `py_str`
allocates, an allocation can trigger a GC pass, and a finalizer run by that
pass may re-enter this Session and re-lock a non-reentrant `Mutex`.
`lock_recover` maps a poisoned lock to `into_inner()`, so a panic there
would not even surface. Reads `session_id` and `state` out from under the
guard and drops it before formatting. Recorded in the module docs as a rule
for the helper rather than a one-off.

**Session's repr used `id=`, not the constructor's `session_id=`.** It
parsed and then raised `TypeError: got an unexpected keyword argument 'id'`
-- arguably worse than the old form, which failed at `compile()`.

**PathMappingRule corrupted Windows paths silently.** Not a `{:?}` at all,
so it was invisible to the search that found the others: it hand-rolled
`'{}'` with no escaping. `C:\temp` rendered as `'C:\temp'`, which Python
reads as `C:` + TAB + `emp` -- it parses and gives back a different string.
An apostrophe in a path closed the literal early. This is the only repr
found that corrupts on its most typical input, a Windows destination path,
which is what path mapping exists to produce. Now routes through `py_str`.

**ESC was missing from the test inputs.** Rust's `Debug` special-cases only
the quote, the backslash, and NUL/tab/CR/LF; every other control falls
through to the unparseable brace form. So ANSI colour sequences in captured
stdout were almost certainly the highest-frequency trigger of this bug in
the field, and no case exercised them. Adds ESC, U+0001 and U+001F, and
narrows the module docs, which previously implied only non-ASCII was
affected. `"a\x00b"` was also mislabelled as representative of C0 when it
passes only because NUL has its own arm.

Also corrects two claims of mine that were wrong:

* A test comment said `ActionStatus` has no `__eq__`. It does
  (types.rs:337), comparing seven fields. The real reason its repr cannot
  round-trip is that the repr emits two of them, and it cannot be fixed by
  adding fields because `started_at`/`ended_at` are not constructor
  arguments. The misleading `eval()` assertion is replaced by one that pins
  the lossiness explicitly, so a later change to the field list is
  deliberate.
* `py_repr`'s docs said delegating "retires the bug class". It retires it
  for `sessions/`; `model/` and the rest of `expr/` still carry it. Scoped
  the wording and pointed at the tracking note.

Adds coverage for `Session` and `PathMappingRule`, which the first revision
omitted -- both are constructible in a unit test, so the omissions were
oversights rather than constraints. `Session` skips the NUL case: the id
becomes a working-directory path component and the filesystem rejects it
before any repr is taken, which is a constructor constraint, not a repr
gap.

176 tests in the sessions module, up from 113. Two new mutants against a
green baseline: reverting PathMappingRule to hand-rolled quoting fails 21,
reverting Session's keyword fails 19. The pre-existing
`TestPathMappingRuleRepr` in test/openjd/expr/test_path_mapping.py passes
unmodified, which is the evidence that the change is a no-op for
well-behaved paths.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Windows CI failed 8 of the new TestSessionRepr cases: Session names its
working directory after the session_id, so the inputs are bounded by what
a filename may contain, not by what the repr can render. ESC, U+0001,
U+001F, tab, newline, CRLF, the double quote and the backslash are all
legal on APFS and ext4 and rejected by Windows -- 'The filename, directory
name, or volume label syntax is incorrect. (os error 123)'.

The previous revision excluded only NUL, which is the one case macOS
caught. That fixed the instance rather than the class.

Now filtered by predicate -- the C0 controls plus the punctuation Windows
reserves -- so a new HOSTILE_STRINGS entry is classified automatically
instead of silently breaking one platform. 10 of 19 cases survive,
including U+00A0 and U+3000, the characters this change exists for.

The 9 excluded are not left unverified: ActionResult and PosixSessionUser
parametrize the full list through the same py_repr::py_str helper and
touch no disk. What Session verifies is that it routes through that helper
at all, and that its repr keyword matches its constructor.

Adds test_the_case_filter_keeps_the_canonical_trigger so the filter cannot
quietly empty out and leave the sweep asserting nothing.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant


Back | FazBrowse Home | New Git URL