feat!: opt-in resolved-value caps and evaluation-budget plumbing - #399
Conversation
97ddc37 to
e680f82
Compare
|
Reviewed at None of these is a regression against Three prior review conclusions land correctly and are worth naming. The duplicate-error bug from #383 ( Spec groundingNone of the four is a conformance finding. Each measures the code against this PR's own claims: the new doc comments, the in-repo
What that means per finding, since the four sit at different distances from the spec:
Unrelated to these four, and noted only because I was in the spec: §5.1 sets "Minimum length: 1 character" on 1. The
|
| Field | Ceiling on a literal | Overshoot vs the CLI default | Why |
|---|---|---|---|
args[*] |
1,048,576 chars | 8x on Linux (131,072), 32x on Windows (32,767) | no spec length limit on an argument |
embedded data |
1,048,576 chars | unbounded relative to a caller's own cap, 256x for a 4 KiB policy | §6.1.2 sets no limit |
command |
1,024 chars | none in practice | EffectiveLimits::max_command_len (structure.rs:465) is tighter than any plausible caller cap |
So command is safe by accident and args/data are not.
Whether this reads as a bypass or as a late failure depends on who executes the job. CallerLimits is caller policy applied at decode, so for a consumer whose executor is not openjd-sessions with SessionLimits populated (the Python openjd-sessions, which has no equivalent, or a Rust worker that leaves SessionLimits::default()), validation is the only gate that exists and writing the value literally removes it. Through the CLI, where both are set, it degrades to a late failure instead:
validation: literal 200-char arg, cap 100 => ACCEPTED
validation: expr 200-char arg, cap 100 => REJECTED: resolves to at least 200 characters, exceeding the maximum of 100.
runtime: literal 200-char arg, cap 100 => Err("Failed to resolve args[0]: resolved value is 200 characters, exceeding the maximum of 100.")
Two things compound it. The 1 MiB ceiling is per field and nothing caps the argv count, so many literal arguments multiply it. And the ordering is backwards: {{ 'A' * 10000000 }} is the deliberate construction and is caught, while a plain long string needs no expression at all and is not. The check only ever fires on values containing {{ }}.
This was predicted in the #373 review, on specs/expr/format-string.md:
validate_fsreturns early whenfs.is_literal(), before callingvalidate_expressionsat all. A purely literal format string that exceeds a length limit will not produce amin_resolved_lenfor the enforcing code to check. Whatever enforces the limit needs to handle the literal case separately, or that early return has to go.
The answer then was "This is planned for future PRs", and this is that PR. It also falsifies specs/cli/check.md as written, "the CLI surfaces it at check time whenever an action command/args value is guaranteed to exceed it", since a literal is the one value that is guaranteed.
Suggested fix: check fs.raw().chars().count() against the constraint before the early return, or hoist the constraint check above it. Same literal-versus-interpolated asymmetry class that #383 fixed twice, for the hardcoded 2048 env-var value and for STRING range literal empties.
2. Three units are conflated: scalars compared against byte and UTF-16 limits
Both gates compare chars().count() (format_strings.rs, runner/mod.rs:534, embedded_files.rs:519). OS_MAX_ARG_LEN is documented as "The maximum character length of a single process argument" and then sourced from limits that are neither: Linux MAX_ARG_STRLEN is 131,072 bytes, the Windows CreateProcess limit is 32,767 UTF-16 code units, macOS ARG_MAX is 1 MiB bytes.
validation: 100 CJK chars (300 UTF-8 bytes), cap 100 => ACCEPTED
validation: control, 101 ASCII chars, cap 100 => REJECTED
runtime: 100 CJK chars (300 UTF-8 bytes), cap 100 => Ok
Under the CLI default a 131,072-character CJK argument is 393,216 bytes. It passes check, passes the run-time cap, and execve still returns E2BIG, which is the failure this PR sets out to make legible. On Windows, 32,767 astral-plane characters are 65,534 UTF-16 units. The error direction is acceptance, never rejection, so no valid job breaks.
#383 spent four rounds unifying these comparisons on characters and that was right, because the spec limits are stated in characters. These two are not spec limits, they are OS limits in bytes, so inheriting the convention inverts its purpose: on the spec limits characters avoided false rejection, here they produce false acceptance of exactly the input the cap exists to stop.
Suggested fix: compare s.len() for the POSIX cap and s.encode_utf16().count() on Windows, or keep characters and derive the constant conservatively (divide by 4 and by 2). Either way name the unit in the field docs.
Related, on the constants themselves. The Linux kernel measures with strnlen_user, which counts the terminating NUL, so the largest argument that actually passes is 131,071 bytes and a 131,072-byte argument satisfies the cap and still gets E2BIG. Worth checking rather than taking from me, since I have not run Linux here. On Windows 32,767 bounds the entire CreateProcess command line including the image name, separators and quoting, so it is not a single-argument maximum, and a per-argument cap set to the total leaves the real failure, several arguments summing past it, unconstrained. ARG_MAX on macOS is likewise the total for argv plus environ. Nothing here caps the argv count or the total, which is what E2BIG is usually about, so the docs should say which half of the threat the cap covers.
3. The "job creation" stage does not exist for any of the four new fields
No file under crates/openjd-model/src/job/ is touched. create_job never calls validate_format_strings (only validate_v2023_09/mod.rs:198 and :260 do), it builds its own FormatStringOptions at create_job/mod.rs:77, ranges.rs (eight sites) and instantiate.rs:59/:186 without reading ctx.caller_limits, and JobTemplate::default_validation_context() (job_template.rs:90) returns a context with default limits. The CLI's own run shows it: execution.rs:135-136 builds revision_ctx from the profile alone and hands that to create_job.
The budgets fail exactly where a budget matters, once real values are bound:
template validated under a 1000-byte eval budget
create_job with N=100000 under the 1000-byte budget => ACCEPTED
control, same expression fully static at validation => REJECTED: Expression memory usage (100136 bytes) exceeded limit (1000 bytes)
{{ ('A' * Param.N)[0] }} in a task-parameter range costs nothing at validation, because Param.N is unresolved and the multiplication never allocates. At job creation with N = 100000 it allocates 100 times the caller's budget and is accepted. A submitting service that lowers max_eval_memory_bytes to protect its own process gets the 100 MB default during parameter-space expansion, which is where the allocation actually happens.
Claims in this PR that this falsifies:
CallerLimits::max_resolved_arg_len: "Enforced at template validation and job creation on the guaranteed lower bound"CallerLimits::max_eval_memory_bytes: "template validation, job creation, and the session runtime all evaluate under the same budget"specs/model/validation.md: "These bound each segment's evaluation identically at validation, job creation, and run time"specs/expr/format-string.md: "a caller that lowers a budget fails at static validation, before any resolution runs"- the PR description: "Job creation: the same checks with real parameter values bound"
- the commit message: "applied at check, summary, run decode/creation"
Suggested fix in two halves. The budgets are worth plumbing into create_job, since that is where the exposure is. The two caps have no job-creation stage available, because command/args/data are @fmtstring[host] and resolve only at the session, so for those the honest story is two stages. Either way, default_validation_context() silently dropping caller limits is a trap for the next field that needs them.
4. The lower bound is per segment, so one concatenation defeats the headline case
{{ 'A' * 10000000 }} => REJECTED: resolves to at least 10000000 characters, exceeding the maximum of 100.
{{ Session.WorkingDirectory + 'A' * 10000000 }} => ACCEPTED
{{ Session.WorkingDirectory }}{{ 'A' * 200 }} => REJECTED: resolves to at least 201 characters, exceeding the maximum of 100.
{{ Param.X + 'A' * 200 }} => ACCEPTED
validate_expressions accumulates per segment, and a segment whose root value is unresolved contributes 0 no matter how much of it is statically known (format_string.rs:335-341). Two segments work, which is what arg_bound_with_unresolved_part_fails pins; one segment doing the same concatenation does not.
The run-time check still catches it, so this is not a hole. But the PR describes the bound as "literal text plus already-computable expression parts, with unknowns (e.g. worker-host variables) counting as zero", which reads as sub-expression granularity, and concatenating inside a single interpolation is the idiomatic form.
Suggested fix: correct the description. A real sub-expression bound means carrying a minimum length through Unresolved in the evaluator and deserves its own issue rather than being folded in here.
Suggested dispositions
| # | Severity | Fix |
|---|---|---|
| 1 | Blocking. A caller-facing cap that a naive template defeats, measured 8x to 256x over policy | One check before the is_literal() early return, plus a test |
| 3 | Blocking as a documentation claim. Six places assert an enforcement stage that does not exist | Docs at minimum; plumbing the budgets into create_job is the substantive fix |
| 2 | Should fix. Leaves the original E2BIG intact for non-ASCII at every gate |
One-line unit change per gate; behaviour change, so it needs tests |
| 4 | Docs only | Correct the description of the bound; file an issue if a sub-expression bound is wanted |
Test coverage gaps
- No literal-value case at any gate (finding 1). If the early return is fixed, that fix needs one.
- No non-ASCII case at any gate (finding 2), so the unit is unpinned in either direction.
- Nothing asserts the budgets at job creation (finding 3), because they are not there.
- Nothing pins the single-segment concatenation behaviour (finding 4), so it could change silently either way.
checkhas CLI tests;summary,run's decode, and thecreate_sessionSessionLimitsmirror have none.
Smaller items
- The CLI's policy is encoded twice in production code:
common::caller_limits()sets the model-side cap, andrun/execution.rs:291-298independently hand-buildsSessionLimits { max_resolved_arg_len: Some(OS_MAX_ARG_LEN), .. }. Add a data cap or a budget tocaller_limits()and the session keeps the old policy silently.openjd-sessionsalready depends onopenjd-model, soFrom<CallerLimits> for SessionLimitsremoves the drift. The mirror constant incli_tests.rsis a reasonable compromise for a binary crate. - Budget violations are reported as parse failures:
Failed to parse interpolation expression at [0, 18]. Expression memory usage (100136 bytes) exceeded limit (1000 bytes). Pre-existing wording, but this PR routes a resource error through it andlowered_memory_budget_fails_static_blowup_at_validationnow pins "Failed to parse" for it. validate_expressionslost a compile-time guarantee. The library moved from&FunctionLibrarytoFormatStringOptions, where it defaults toNoneand the evaluator falls back toFunctionLibrary::for_profile(&ExprProfile::current()). A future call site that forgets.with_library()compiles and validates against the current profile instead of the scope's, which is a silent scope escalation rather than an error. Both present call sites pass it.path_formatis plumbed but unused.validate_expressionsnow honoursopts.path_format, butFsEval::options()and the sessionsfs_options()both leave it atPathFormat::host()whilecreate_jobresolves template-scope fields withPathFormat::Posix. So expr: Have thepath()constructor return unresolved[path] when validating for the host context #377's divergence is untouched, and the restated claim that "validation observes exactly the values resolution will produce" still does not hold for template-scope fields on a Windows host.FsEvalis already split intotemplate_ev/host_ev, which is the natural seam if the fix goes that way rather than via expr: Have thepath()constructor return unresolved[path] when validating for the host context #377'sunresolved[path]approach.- The cross-gate target invariant is unpinned.
target_type()returningNonefor the two new variants encodes "resolution passes no target type", which is true today inresolve_action_argsand embedded-file materialization and verified only by reading them. feat!: enforce resolved-value constraints at template validation #383's review removed thetargetedflag precisely because a gate-1/gate-3 target mismatch is the failure mode this design guards against. A test resolving one value through both paths and asserting agreement would pin it. - No CLI override.
openjd checkis used as a conformance oracle and now returns host-dependent verdicts on spec-valid templates with no way to opt out, and by finding 2 it rejects the ASCII form while accepting the non-ASCII equivalent of the same length. A--max-resolved-arg-len <N|none>would help. SessionConfigstill has noDefault, so seven test files change for one added field.#[non_exhaustive]plus a builder, or aDefaultimpl, would stop this recurring at every new field.- Not this PR's defect: the
expect("unresolved[T] always has exactly one parameter")panic raised in the feat(expr)!: return StaticResolution from FormatString::validate_expressions #373 review is still on this path, tracked as MakeExprTypeconstructors fallible, with a maximum nesting depth #328.
What I did not verify
The Windows and Linux argument limits themselves, the conformance run (1139/1139 claimed on Windows), the two pre-existing Windows cross-user failures, and the strnlen_user off-by-one in finding 2.
Adds opt-in caller caps for the resolved-value fields the spec deliberately leaves uncapped, plus configurable expression evaluation budgets, enforced at all three processing stages (template validation, job creation, run time). - openjd-expr: FormatStringOptions gains with_memory_limit / with_operation_limit (the Expression Language spec's memory-bounded evaluation lever), honored by resolve_with / resolve_string_with. FormatString::validate_expressions now takes the same FormatStringOptions as resolution, so validation observes exactly the resolution the caller will perform — target type and budgets included. - openjd-model: CallerLimits gains max_resolved_arg_len / max_resolved_data_len (opt-in caps on action command/args and embedded-file data; §5.1/§5.2/§6.1.2 set no spec maximum, so the defaults are None) and max_eval_memory_bytes / max_eval_operations. Template validation (pass 8) enforces the caps on the guaranteed lower bound of every possible resolution and evaluates every format-string expression under the budgets. decode_environment_template now takes CallerLimits, closing the gap where environment templates bypassed caller policy at decode. - openjd-sessions: new SessionLimits (SessionConfig.limits) mirrors the four fields. resolve_action_args enforces the arg cap on the resolved command and every final argv entry (after null-skip/list-flatten); embedded-file materialization enforces the data cap; every format-string evaluation in the runtime honors the budgets. The runtime is the authoritative enforcement point — its checks hold for jobs that never passed through this client's validation. - openjd-cli: opinionated default max_resolved_arg_len = the host OS's single-argument maximum (Linux 131072 MAX_ARG_STRLEN, Windows 32767 command-line limit, otherwise 1 MiB ARG_MAX), applied at check, summary, run decode/creation, and mirrored into the session. Library defaults stay None. Conformance suite passes with the cap active (1139/1139 on Windows, the tightest cap). BREAKING CHANGE: FormatString::validate_expressions signature changed to (symtab, &FormatStringOptions); decode_environment_template takes a CallerLimits argument; CallerLimits and SessionConfig gained fields (struct-literal construction must add them or use ..Default::default()). Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: the CallerLimits docs claimed the resolved-value caps and evaluation budgets are enforced at job creation, but create_job applies neither — it carries command/args/data forward as unresolved host-scope format strings (nothing to measure), and its own resolution sites (job name, ranges, parameter spaces) evaluate under the spec default budgets. Correct the field docs and the matching spec passages to say what actually happens: caps and budgets apply at template validation and, when mirrored into SessionLimits, at run time. Evaluating carried-forward fields at job creation is possible future work. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: validate_fs_with returned early for literal format strings before the constraint ran, so a purely-literal args element or embedded-file data blob over an opted-in cap passed validation and only failed at run time — the raw-text passes length-check neither field (command alone was incidentally covered by the 1024 raw-text cap). A literal is the one case where the resolved value is known exactly, so the literal path now checks the raw length against ResolvedString / ArgListItem caps directly; the spec-mandated constraints keep the early return, since the raw-text passes already cover their literal cases and re-checking would double-report. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: let-binding evaluation bypassed the caller's evaluation budgets on every path — pass-8 validation (validate_let_bindings and the step-level range-scope bindings) evaluated with the library but no limits, and openjd_model::evaluate_let_bindings, which the session runtime calls for env/step/wrap-env bindings, built its EvalBuilder the same way. A caller who lowered max_eval_memory_bytes specifically to bound expression blowups still evaluated let: [x = 'a' * 50000000] under the 100 MB default at validation and again on the worker. evaluate_let_bindings now takes the memory/operation budgets (None = spec defaults), all session-runtime callers pass SessionLimits' budgets, and the two validation-side sites evaluate through the same FsEval budgets as format strings. Session gains a test-only limits setter so the runtime wiring is testable end to end. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
7e9b7cb to
5a5901b
Compare
…ters Review follow-up: OS_MAX_ARG_LEN encoded per-OS figures that are not measured in the units the checks count. The caps compare chars().count(), but Linux MAX_ARG_STRLEN (131072) is bytes and the Windows command-line limit (32767) is UTF-16 code units — so 131072 non-ASCII characters passed check on Linux and still drew E2BIG at execve, and astral-plane characters under-counted 2x on Windows. The constant was not a sound proxy for the OS limits it cited. Replace it with DEFAULT_MAX_ARG_LEN = 32K characters on every platform, documented as an opinionated reasonable default rather than an encoding of any OS's limit: it is far beyond any reasonable single argument, at most 128 KiB of UTF-8 (a char is at most 4 bytes, so it cannot exceed Linux's per-string byte limit), and approximately the Windows command-line capacity. The OS itself and the worker's run-time enforcement remain authoritative. The CallerLimits field docs now state the character-vs-bytes/UTF-16 units mismatch so library callers pick their own values with encoding headroom. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: resolve_string_with concatenated every segment into an unbounded String — only each individual segment's evaluation was memory-bounded, and the per-segment limit does not compose (1000 segments of 'A' * 99999999 fit in a 1 MB format string yet concatenate to gigabytes). validate_expressions already caps its accumulation for exactly this reason, but the run-time resolution paths (command, args, env values, embedded-file data) had no equivalent ceiling: a worker could OOM materializing the string before any resolved-length cap fired. The concatenation buffer is now charged against the same memory limit as segment evaluation, checked after each expression segment renders: resolution fails with the standard MemoryLimitExceeded error instead of materializing past the budget, bounding peak memory at roughly twice the limit. This protects every resolution path under the 100 MB default — no opt-in required — and scales down with a lowered max_eval_memory_bytes. Literal text is never charged on its own: it involves no evaluation, is already bounded by MAX_FORMAT_STRING_LEN, and validation does not charge it either, so purely-literal strings resolve under any budget. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: the step-level let bindings that the parameterSpace block pre-evaluates into the range scope still built a raw EvalBuilder with the library but no limits — the one evaluation site in pass 8 left outside FsEval after the let-binding budgets landed. Ordering makes it the worst one to miss: within a step the parameterSpace block runs before the step-level let pass, so for a step declaring both, the unbudgeted evaluation performed the full allocation before the budgeted re-evaluation ever fired, making the budget bypassable for exactly the input it is meant to bound. Route it through FsEval::budgeted like every other evaluation in the pass, with a test pinning the pre-pass path (a step with both a parameterSpace and a let blowup fails under a lowered budget, passes under defaults). Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up on the resolution-buffer bound: resolve_string_with charged the whole accumulated buffer — literals included — while validate_expressions never charges literals, so the two stages disagreed. A literal-heavy field with any interpolation (the embedded file data shape: a large literal script body plus one small interpolation) passed openjd check under a lowered max_eval_memory_bytes and then drew MemoryLimitExceeded on the worker, breaking the documented invariant that validation observes exactly what resolution produces. Literal text needs no budget of its own: it involves no evaluation and MAX_FORMAT_STRING_LEN (1 MB) already bounds it. Both stages now charge the same quantity — the accumulated bytes rendered by concrete expression segments: - resolve_string_with tracks a charged counter (expression-rendered bytes only) against the memory limit, still closing the composition gap the bound exists for; - validate_expressions applies the identical charge, so a fully static string that resolution rejects on a budget now fails at check too. Unresolved segments contribute 0, so validation only ever under-counts host-dependent content and the run-time check remains authoritative for it. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
…bound semantics Follow-ups from the full-branch review (written at 97ddc37; its findings 1-3 were addressed by the intervening commits): - The CLI's policy was encoded twice: common::caller_limits() for the model-side stages and a hand-built SessionLimits in create_session. SessionLimits now implements From<&CallerLimits>, mirroring the four run-time-relevant fields, and create_session derives its limits from the same caller_limits() value — adding a cap or budget to the policy can no longer silently skip the session. - Tests pin the semantics the review found unpinned: the caps count Unicode scalar values (100 three-byte CJK characters satisfy a 100-character cap, 101 fail it — at validation, on the literal path, and at run time), and the validation bound is per segment (a single expression concatenating a huge static part onto an unresolved part contributes zero and defers to the run-time check, while the same content as two segments is rejected — the documented granularity limitation, now pinned so it cannot change silently). - check.md notes the cap bounds each single argument, not the argv count or total (ARG_MAX-style budgets are possible future work). Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
What was the problem/requirement? (What/Why)
A template argument like
"{{ 'A' * 10000000 }}"expands to a 10 MBstring when the job runs. The spec deliberately sets no size limit
on action commands (§5.1), arguments (§5.2), or embedded-file contents
(§6.1.2), so such a template passes
openjd checkand job submission,then degrades the worker host — an opaque spawn failure (
E2BIG), or agiant file on disk.
Two knobs were missing: a way for a caller to opt into limits on
these fields (a default-on cap would reject spec-valid templates), and
a way to configure the expression evaluation budgets — the engine's
existing 100 MB / 10M-operation bounds were hard-wired at every call
site.
What was the solution? (How)
Both knobs join the
CallerLimitspolicy struct inopenjd-model(all default
None= nothing beyond the spec) and are enforced at allthree processing stages:
guaranteed lower bound on the length of every string it could
resolve to — literal text plus already-computable expression parts,
with unknowns (e.g. worker-host variables) counting as zero. A bound
over the cap means no run could ever conform, so
checkrejects itwith a normal field-path error. Fully-static values get exact checks,
per-element for lists that expand into multiple arguments.
never went through this client's validation): the final command,
each final argv entry, and each embedded-file body are checked just
before use, configured via a new
SessionLimitsonSessionConfig.The budgets ride the same plumbing:
FormatStringOptionsgainswith_memory_limit/with_operation_limit, honored everywhere, andvalidate_expressionsnow takes the same options object as resolution— so validation can't check a different configuration than resolution
uses.
The
openjdCLI enables the argument cap by default, at the host OS'ssingle-argument maximum (Linux 131,072; Windows 32,767; otherwise
1 MiB): the 10 MB example now fails at
check. Library defaults stayuncapped. Note the CLI cap describes the checking machine — a Windows
worker's smaller limit is still enforced by its own runtime.
Also fixed:
decode_environment_templatetook noCallerLimitsatall, so environment templates bypassed caller policy at validation. It
now matches
decode_job_template, and the JS/WASM bindings forwardthe caller's limits instead of dropping them.
How was this change tested?
per the repo standard: lower-bound rejections (mixed known/unknown
segments, list flattening per element), at/under-cap and no-cap
controls, budget violations at validation and run time, the
environment-template plumbing, and the CLI default (a 2,000,000-char
argument fails
checkon every platform).cargo test --workspacegreen, except two pre-existingopenjd-sessionsWindows cross-user failures that reproduceidentically on
origin/main(dev-machine environment, unrelated).1139 passed, 0 failed. Clippy (
-D warnings) and rustfmt clean.Was this change documented?
Yes — all public items have doc comments, and the spec documents for
all five affected crates (
specs/expr/,specs/model/,specs/sessions/,specs/cli/,specs/openjd-for-js/) are updated inthe same commit: new API surfaces, the per-field constraint table, and
the CLI's default-cap policy.
Is this a breaking change?
Yes (pre-1.0 minor bumps;
feat!with aBREAKING CHANGEfooter):openjd-expr:validate_expressions(symtab, lib, target_type)→validate_expressions(symtab, &options); move the library and targettype onto
FormatStringOptions, ideally sharing the options used forresolution.
openjd-model:decode_environment_templatetakes a third&CallerLimitsargument;CallerLimitshas four new fields (use..Default::default()).openjd-sessions:SessionConfighas a newlimitsfield(
limits: Default::default()for the old behavior).Does this change impact security?
Hardening: enforceable, configurable bounds against resource
exhaustion from expression-generated values, plus closing the
environment-template limits bypass. No new files, processes, or
permissions; the checks don't allocate beyond what (already
budget-bounded) evaluation produces.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.