fix: Enforce the 512-character cap on a let binding identifier - #348
Conversation
| # requires a 512-character name to be accepted, so borrowing the §7.1 cap would | ||
| # limit it to 64 and reject a template the spec permits. EXPR gates whether | ||
| # `let` exists at all and moves neither cap. | ||
| LET_MAX_IDENTIFIER_LEN = 512 |
There was a problem hiding this comment.
Parity note: this cap lands only in the v0 pure-Python path, and there is no corresponding coverage on the Rust-backed side. test/openjd/model_v1/test_let_bindings.py has no length-boundary test, and test_known_gaps.py records no gap for it — so if openjd-model does not enforce a 512-character <UserIdentifier> cap, the two implementations now silently disagree on a template the spec section this PR cites is specifically about.
AGENTS.md calls out reference parity as a tracked artifact and test_known_gaps.py as the place divergences get recorded (xfail, driven to zero). Either a matching v1 test or a known_gaps entry would keep this from drifting unnoticed; a v0-only cap with nothing on the v1 side is the shape that parity tracking exists to catch.
There was a problem hiding this comment.
Ack and there is a rust PR.
There was a problem hiding this comment.
Parity is covered by OpenJobDescription/openjd-rs#358, which adds the same cap on the Rust side with its own seven tests, so the two implementations agree rather than diverging.
On the tracking ask specifically: not adding a known_gaps entry, because there is no gap left to record once #358 merges, and an xfail that never fails is worse than nothing. Not adding a v1 test either, since the v1 surface is evaluate_let_bindings, which runs on already-validated templates and does not perform this check in either implementation.
The honest residue is a release-ordering window: this repo's cap lands with this PR, the Rust one lands with #358, and they release independently, so between the two releases the pinned behaviour differs by version. The conformance fixture that covers this is parked in proposed/ in openjd-specifications#164 for exactly that reason and moves out once both have shipped. Happy to add a tracking entry instead if you would rather that window were recorded here.
Review finding on OpenJobDescription#348. The validator is a `field_validator` on the whole `let` list, so pydantic anchors the error at `steps[0] -> let` with no list index. Measured on a four-binding template where the third name is 513 characters: the error was steps[0] -> let: A 'let' binding name must be at most 512 characters long which identifies neither the binding nor its position. With 50 bindings allowed that is not diagnosable. Truncate rather than omit, as the reviewer suggested. The message now carries a 32-character prefix and the true length: A 'let' binding name must be at most 512 characters long: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'... (513 characters) Total error text goes from 110 to 166 characters, so it stays bounded while becoming locatable. The original concern about a 513-character name dwarfing the diagnostic still holds against interpolating the name in full, which is why this truncates. Two assertions pin it, both mutation-checked: reverting to the bare message fails `test_name_513_chars` and the new `test_name_513_chars_names_the_offending_binding`, which uses a multi-binding `let` to cover the case the finding was about. No matching change in openjd-rs: its path is `steps[0] -> let[0]`, so the index already identifies the binding there. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Template Schemas §3.6.1 caps a `<UserIdentifier>` at 512 characters. Nothing checked it, so a 513-character `let` binding name was accepted. The conformance fixture pinning this is `EXPR/job_templates/proposed/3.6.1--let-identifier-513.invalid.yaml`, added by openjd-specifications#164, which failed here and in openjd-rs alike. The cap is a flat 512 and deliberately does not reuse the §7.1 `<Identifier>` cap that `NameIdentifierLengthMixin` and the inline `512 if "FEATURE_BUNDLE_1" in context.extensions else 64` checks apply. §3.6.1 states one maximum for a `<UserIdentifier>` and gates it on no extension, and the fixture pair proves the flat reading: the 512-character accept twin `EXPR/job_templates/3.6--let-boundary-edges.yaml` declares EXPR alone, so borrowing the §7.1 cap would limit it to 64 and reject a template the spec permits. The two caps share the number 512 and nothing else, so this adds `LET_MAX_IDENTIFIER_LEN` beside the existing `LET_MAX_BINDINGS` with its own citation. EXPR gates whether `let` exists at all and moves neither cap. All four `_validate_let` field validators route through `validate_let_field` and then `parse_let_bindings`, so the check goes in `parse_let_bindings` after the `_LET_NAME_RE` match and every scope is covered by one insertion. The message omits the offending name: at 513 characters it would dwarf the diagnostic. Six tests, mutation-checked: removing the check fails all three reject tests while the three accept tests keep passing, so the accept cases act as negative controls against a cap set too low. The default `_job` helper declares EXPR alone, which is what would catch a regression to the FEATURE_BUNDLE_1-gated cap; the `_with_fb1` variants pin that declaring FEATURE_BUNDLE_1 does not move it. The matching openjd-rs change is OpenJobDescription/openjd-rs#358. Design note: SuperDaveDocs docs/conformance-0901/4.6-let-513/fix.md Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The rationale lives in the commit message, the PR description and the design note. The code only needs the citation and the one fact a reader editing this line must not miss: the cap is flat, so it is not the FEATURE_BUNDLE_1-gated section 7.1 cap. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Review finding on OpenJobDescription#348. The validator is a `field_validator` on the whole `let` list, so pydantic anchors the error at `steps[0] -> let` with no list index. Measured on a four-binding template where the third name is 513 characters: the error was steps[0] -> let: A 'let' binding name must be at most 512 characters long which identifies neither the binding nor its position. With 50 bindings allowed that is not diagnosable. Truncate rather than omit, as the reviewer suggested. The message now carries a 32-character prefix and the true length: A 'let' binding name must be at most 512 characters long: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'... (513 characters) Total error text goes from 110 to 166 characters, so it stays bounded while becoming locatable. The original concern about a 513-character name dwarfing the diagnostic still holds against interpolating the name in full, which is why this truncates. Two assertions pin it, both mutation-checked: reverting to the bare message fails `test_name_513_chars` and the new `test_name_513_chars_names_the_offending_binding`, which uses a multi-binding `let` to cover the case the finding was about. No matching change in openjd-rs: its path is `steps[0] -> let[0]`, so the index already identifies the binding there. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
28de2b2 to
c476cf8
Compare
Review finding on OpenJobDescription#348. The three accept-side tests bound a 512-character name and never referenced it, so they proved `parse_let_bindings` does not raise but not that the binding is usable, which is the property the conformance fixture is about. Each now interpolates the bound name in the script args, so the name flows through the variable-reference validation and into the expression parser. All 24 tests still pass, which answers the finding's concern directly: no cap further down that path, in `Identifier`, `FormatString`, or the Rust expression layer, rejects a 512-character binding. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| # Truncated rather than omitted: the caller is a field_validator on the | ||
| # whole list, so the error path is `let` with no index to identify which | ||
| # binding is over. | ||
| if len(name) > LET_MAX_IDENTIFIER_LEN: |
There was a problem hiding this comment.
The length check is placed after the _LET_NAME_RE check, which defeats the truncation this block exists to provide. The comment above says the name is truncated to 32 chars so the error stays readable, but line 948 (A 'let' binding name must be a valid identifier: {name!r}) interpolates the full, untruncated name — and that is the branch a long name hits whenever it also contains a disallowed character or starts with a digit/uppercase.
So let: ["A" * 100000 + " = 1"] (uppercase first char → fails the regex) produces a ~100 KB ValueError that pydantic wraps into the DecodeValidationError message, while the same name lowercased produces the nice 32-char-truncated one. The over-long case that is most likely to be adversarial is exactly the one that skips the guard.
Moving the length check above the regex check fixes both: nothing over 512 characters ever reaches an interpolation site.
if len(name) > LET_MAX_IDENTIFIER_LEN:
raise ValueError(
f"A 'let' binding name must be at most {LET_MAX_IDENTIFIER_LEN} "
f"characters long: {name[:32]!r}... ({len(name)} characters)"
)
if not _LET_NAME_RE.match(name):
raise ValueError(f"A 'let' binding name must be a valid identifier: {name!r}")Worth noting {binding!r} at lines 941 and 957 is unbounded for the same reason (pre-existing), so the reordering only closes the name path — but the name path is the one this PR is adding a bound for.
| # §3.6.1: maximum length of a `let` binding's `<UserIdentifier>`. Flat, so not | ||
| # the §7.1 cap NameIdentifierLengthMixin applies: that one is 64 without | ||
| # FEATURE_BUNDLE_1, and a 512-character name must be accepted with EXPR alone. | ||
| LET_MAX_IDENTIFIER_LEN = 512 |
There was a problem hiding this comment.
Scope question on the cap: parse_let_bindings is only reached from the four let field validators (lines 1077, 1127, 1174, 3558). But <UserIdentifier> in §3.6.1 also covers the names bound inside an expression — comprehension variables and inline let ... in ... bindings — e.g. {{ [aaaa… for aaaa… in Param.Items] }}. Those go through ExprNode/the Rust engine (self._parsed.local_bindings in _format_strings/_nodes.py), which this check never sees.
If the intent is "enforce the §3.6.1 identifier cap," the two forms should agree; if the intent is narrower — only the let field, because the engine already bounds its own binders — the constant name and comment are misleading, since LET_MAX_IDENTIFIER_LEN reads as the identifier limit generally. Either extending the check to local_bindings or narrowing the comment to say the engine owns the in-expression form would remove the ambiguity.
test_comprehension_shadows_let shows the comprehension path is already exercised in this file, so a boundary test there would be cheap if the cap is meant to apply.
openjd-model 0.6.0 carries openjd-rs#358, which enforces Template Schemas §3.6.1's 512-character cap on a `let` binding's `<UserIdentifier>`; before it a 513-character name was accepted. The v0 side has covered this since OpenJobDescription#348, in `test/openjd/model_v0/v2023_09/test_let_bindings.py`. The v1 path had nothing, so the bump brought the enforcement with no test on this side to hold it. Measured through `decode_job_template` on both implementations before writing the assertions. 512 characters is accepted and 513 rejected, identically, with `EXPR` alone and with `EXPR` plus `FEATURE_BUNDLE_1`. The accept at 512 with `EXPR` alone is the case worth having. The cap is flat, not §7.1's `<Identifier>` cap of 64 rising to 512 under `FEATURE_BUNDLE_1`, so a fix built on the wrong constant would reject a template the spec permits. A 65-character control covers the same ground from the other side: over the §7.1 cap, under §3.6.1's, and accepted. The two implementations' messages differ in wording, so the assertion is on the error path and the phrase `exceeds 512 characters`, not on the whole string. Verified: 5572 passed, 24 skipped, 3 xfailed with the 94% coverage gate; ruff, black and mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
What
Template Schemas §3.6.1 caps a
letbinding's<UserIdentifier>at 512 characters. Nothing checked it, so a 513-character name was accepted.Why
The conformance fixture pinning this is
EXPR/job_templates/proposed/3.6.1--let-identifier-513.invalid.yaml, added by openjd-specifications#164. It fails on this implementation and on openjd-rs alike. I found it while running every open conformance-test PR against both implementations, each built from source; it is root cause F-6 of that sweep.Spec text, pinned at the commit the sweep measured against — wiki/2023-09-Template-Schemas.md, L1424-L1454:
That is L1430 and L1441. The section states one maximum and does not gate it on any extension.
Why this does not reuse the §7.1 identifier cap
This is the decision worth reviewing, because reaching for the existing cap is the obvious move and it is wrong here.
This package already carries the §7.1
<Identifier>cap inNameIdentifierLengthMixinand in seven inline512 if "FEATURE_BUNDLE_1" in context.extensions else 64checks. That cap is 64 characters, rising to 512 withFEATURE_BUNDLE_1. §3.6.1's<UserIdentifier>is a different type with a flat maximum.The conformance pair proves the flat reading rather than merely suggesting it. The 512-character accept twin,
EXPR/job_templates/3.6--let-boundary-edges.yamlfrom openjd-specifications#160, declaresextensions: [EXPR]with noFEATURE_BUNDLE_1. Under the §7.1 cap its limit would be 64, so a fix built on that cap would reject a template the spec permits and turn a passing fixture red.The two caps share the number 512 and nothing else, so this adds
LET_MAX_IDENTIFIER_LENbeside the existingLET_MAX_BINDINGS, carrying its own citation and a comment recording why it is not the other one. EXPR gates whetherletexists at all and moves neither cap.Where the check goes, and why one place is enough
Four models carry a
letfield, and all four_validate_letfield validators route throughvalidate_let_fieldand thenparse_let_bindings. The check therefore goes inparse_let_bindings, immediately after the_LET_NAME_REmatch, and every scope is covered by one insertion.The message omits the offending name, because at 513 characters it would dwarf the diagnostic.
The runtime helper
evaluate_let_bindingsis left alone. It runs on templates validation has already accepted, so rejecting there would surface too late.Verification
Full suite: 5531 passed, 24 skipped, 3 xfailed.
ruff checkandmypyclean.ruff format --checkreports both files I touched as already formatted.Two pre-existing failures are unrelated to this change:
TestYAMLLoaderPerformance::test_extra_large_template_performancein bothmodel_v0andmodel_v1asserts a hard 5x speedup and lands at 4.0x-4.9x on my machine. I confirmed they are pre-existing by stashing this change and re-running on unmodifiedmainline, where both still fail, at 4.0x and 4.2x.Six new tests in
test/openjd/model_v0/v2023_09/test_let_bindings.py: 513 rejected and 512 accepted, each at step scope and script scope, plus both cases withFEATURE_BUNDLE_1declared. The file's_jobhelper declares EXPR alone by default, so the default variants are the ones that pin the section above; without them a regression to theFEATURE_BUNDLE_1-gated cap would pass the suite.Mutation-checked. Removing the production check fails all three reject tests and leaves all three accept tests passing, so the accept cases act as negative controls against a cap set too low.
I cleared
__pycache__between runs and confirmed the mutated tree still imports, so the failures are the assertion firing rather than a broken build.Out of scope
The §3.6.1 minimum length of 1 character.
_LET_NAME_REalready rejects an empty name, and the conformance suite covers it.Related
proposed/. It can move out once both implementations release the fix.SuperDaveDocs docs/conformance-0901/4.6-let-513/fix.md