| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
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>
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>
| Back | FazBrowse Home | New Git URL |
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.
A consumer runs the action, captures stdout, and wraps it in an ActionResult — then logs it:
Measured, running the template's action for real and reverting only ActionResult::__repr__:
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 escapeThe 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:
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:
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 RustThree 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:
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
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:
Verification
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:
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:
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.