Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions conformance-tests/2023-09/WRAP_ACTIONS/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# WRAP_ACTIONS Conformance Tests

Conformance tests for the `WRAP_ACTIONS` extension defined in
[RFC 0008 — Environment Wrap Actions](../../../rfcs/0008-environment-wrap-actions.md).

## What this extension does

`WRAP_ACTIONS` adds three new fields to `<EnvironmentActions>`:

```yaml
<EnvironmentActions> ::= the object:
onEnter: <Action> # optional
onWrapEnter: <Action> # NEW — optional
onWrapTaskRun: <Action> # NEW — optional
onWrapExit: <Action> # NEW — optional
onExit: <Action> # optional
```

When an active environment defines the wrap hooks, the runtime runs each
hook *instead of* the corresponding lifecycle action of every inner
environment and task. Each hook receives the wrapped action's fields via
the `WrappedAction.*` template variables:

| Variable | Type | Description |
|-------------------------------|----------------|---------------------------------------------------------------------|
| `WrappedAction.Command` | `string` | The `command` from the wrapped action |
| `WrappedAction.Args` | `list[string]` | The `args` from the wrapped action |
| `WrappedAction.Environment` | `list[string]` | `openjd_env`-defined variables as `["KEY=value", ...]` |
| `WrappedAction.Timeout` | `int` | Timeout of the wrapped action in seconds, or `0` if none |
| `Env.Wrapped.Name` | `string` | Name of the inner environment (only in `onWrapEnter`/`onWrapExit`) |

## RFC rules these tests verify

- **All-or-nothing**: defining any wrap hook requires defining all three
(`onWrapEnter`, `onWrapTaskRun`, `onWrapExit`).
- **Single wrap layer**: at most one environment in the session stack may
define wrap hooks.
- **Variable scope**: `WrappedAction.*` may be referenced only inside the
three wrap hooks; `Env.Wrapped.*` only inside `onWrapEnter`/`onWrapExit`.
- **Extension gating**: wrap hooks are only valid when `WRAP_ACTIONS` is
declared in `extensions:`.
- **Interception**: the wrap hooks run instead of the corresponding
lifecycle actions of inner environments and tasks.
- **Safe forwarding**: with `repr_sh()` from the EXPR extension, arbitrary
user input (quotes, metacharacters, globs, Unicode) reaches the wrapped
process verbatim.

## How these tests are designed

The RFC motivates the wrap hooks with Docker/Apptainer use cases, but
those containers aren't required to *test* the mechanism. These tests use
trivial wrap scripts — typically `echo`, `sh -c`, or `printf` — that
observe the injected `WrappedAction.*` variables and write sentinel
markers to stdout.

A test passes if:
- Each wrap hook runs in place of its corresponding lifecycle action
(verified by sentinels the original action would never emit).
- The injected `WrappedAction.*` variables carry the exact bytes of the
wrapped action.
- The original action does **not** execute.

## Layout

```
WRAP_ACTIONS/
├── README.md (this file)
├── env_templates/ # Env template validation tests
│ ├── 4.2--minimal-wrap-env-template.yaml
│ ├── 4.2--wrap-only-environment.yaml
│ ├── 4.2--wrap-with-timeout.yaml
│ ├── 4.2--empty-actions-no-wrap-no-enter-no-exit.invalid.yaml
│ ├── 4.3--wrap-only-onwrap-task-run.invalid.yaml
│ ├── 4.3--wrap-missing-onwrap-exit.invalid.yaml
│ └── 4.3--wrappedaction-outside-wrap-hook.invalid.yaml
└── jobs/ # End-to-end execution tests
├── wrap-intercepts-simple-echo.test.yaml
├── wrap-original-onrun-does-not-run.test.yaml
├── wrap-enter-and-exit-three-hooks.test.yaml
├── wrap-enter-intercepts-inner-on-enter.test.yaml
├── wrap-exit-intercepts-inner-on-exit.test.yaml
├── wrap-task-command-injected.test.yaml
├── wrap-task-args-preserved.test.yaml
├── wrap-task-environment-injected.test.yaml
├── wrap-task-action-timeout-injected.test.yaml
├── wrap-preserves-shell-metacharacters.test.yaml
├── wrap-preserves-nested-quotes.test.yaml
├── wrap-preserves-unicode-cjk.test.yaml
├── wrap-preserves-empty-arg.test.yaml
├── wrap-glob-characters-literal.test.yaml
├── wrap-path-traversal-literal.test.yaml
├── wrap-outer-env-without-wrap-ignored.test.yaml
├── wrap-runs-for-every-task.test.yaml
├── wrap-ignores-wrapped-action-vars.test.yaml
├── wrap-without-extension-fails.invalid.test.yaml
├── wrap-multiple-wrap-envs-rejected.invalid.test.yaml
└── wrap-partial-hooks-rejected.invalid.test.yaml
```

All execution tests use POSIX shell commands (`sh`, `echo`, `printf`),
so they are gated to `runOn: [posix]`.

## Test matrix — RFC §"Recommended test cases for implementation"

The RFC's container-implementation companion lists scenarios that any
implementation should validate. This matrix maps the security-related
ones to the job bundles in this directory:

| # | Scenario | Test file |
|---|-----------------------------------|-------------------------------------------------|
| 1 | Nested quoting | `wrap-preserves-nested-quotes.test.yaml` |
| 2 | Shell metacharacters | `wrap-preserves-shell-metacharacters.test.yaml` |
| 3 | Path traversal | `wrap-path-traversal-literal.test.yaml` |
| 4 | Shell globbing | `wrap-glob-characters-literal.test.yaml` |
| 5 | Unicode paths | `wrap-preserves-unicode-cjk.test.yaml` |
| 6 | Empty / whitespace-only arguments | `wrap-preserves-empty-arg.test.yaml` |
| 7 | Newlines in arguments | Rejected by the 2023-09 `ArgString` regex — |
| | | no dedicated conformance test needed. |
| 8 | Near-limit command length | Covered by unit tests in the implementation. |
| | | Conformance runners vary in stdout buffer |
| | | limits, so this is left to implementation tests |
| | | rather than conformance. |

## Additional semantics tested

- **Three-hook interception**: each hook runs in place of its
corresponding lifecycle action
(`wrap-enter-intercepts-inner-on-enter`,
`wrap-exit-intercepts-inner-on-exit`,
`wrap-enter-and-exit-three-hooks`).
- **Variable injection**: `WrappedAction.Command/Args/Environment/Timeout`
reach the wrap script with the right values.
- **Outer-non-wrap-env coexistence**: a non-wrapping environment can
appear in the stack alongside the single wrapping environment
(`wrap-outer-env-without-wrap-ignored`).
- **Per-task repeat**: variables re-inject cleanly per task
(`wrap-runs-for-every-task`).
- **Invalid templates**:
- `wrap-without-extension-fails`: hooks require the extension.
- `wrap-multiple-wrap-envs-rejected`: at most one wrap env per session.
- `wrap-partial-hooks-rejected`: all-or-nothing rule (job side).
- `4.3--wrap-only-onwrap-task-run`: all-or-nothing rule (env side).
- `4.3--wrap-missing-onwrap-exit`: all-or-nothing rule (env side).
- `4.3--wrappedaction-outside-wrap-hook`: variable scope rule.

## Running the tests

From the repo root:

```bash
# Run just the WRAP_ACTIONS extension tests
uv run conformance-tests/run_openjd_cli_tests.py 2023-09/WRAP_ACTIONS

# Run only the job execution tests
uv run conformance-tests/run_openjd_cli_tests.py 2023-09/WRAP_ACTIONS/jobs

# Pattern-match a single scenario
uv run conformance-tests/run_openjd_cli_tests.py '*wrap*unicode*'
```

## Writing your own runner

See the top-level [conformance README](../../README.md) for the standard
runner contract. The behavior specific to `WRAP_ACTIONS`:

1. When the env template declares `extensions: [WRAP_ACTIONS]`, the
runner must accept `onWrapEnter`, `onWrapTaskRun`, and `onWrapExit`
under `environment.script.actions`, and must reject the template if
any one of the three is defined without all three.
2. When an environment with wrap hooks is entered, every subsequent
inner-environment lifecycle action and task `onRun` must execute the
corresponding wrap hook in place of the original action, with
`WrappedAction.*` variables populated from the wrapped action's fields.
3. When two or more environments in the session stack define any wrap
hook, the runner must reject the session before entering any
environment.
4. When wrap-hook fields are used without `WRAP_ACTIONS` in the
`extensions` list, the runner must reject the template at validation
time.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# An environment whose `actions` block is empty is invalid: at least one of
# onEnter, onWrapEnter, onWrapTaskRun, onWrapExit, or onExit must be defined.
specificationVersion: environment-2023-09
extensions:
- WRAP_ACTIONS
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.

I think this should already fail because EXPR is missing, so it doesn't test what the comment says.

environment:
name: EmptyActions
script:
actions: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
specificationVersion: environment-2023-09
extensions:
- WRAP_ACTIONS
- EXPR
environment:
name: MinimalWrapEnv
script:
actions:
onEnter:
command: echo
args: ["entered"]
onWrapEnter:
command: echo
args: ["wrap-enter", "{{Env.Wrapped.Name}}"]
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.

Going with WrappedEnv.Name (and likely WrappedStep.Name inside onWrapTaskRun is probably more consistent with the WrappedAction convension.

onWrapTaskRun:
command: echo
args: ["wrap-task", "{{WrappedAction.Command}}"]
onWrapExit:
command: echo
args: ["wrap-exit", "{{Env.Wrapped.Name}}"]
onExit:
command: echo
args: ["exited"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# RFC 0008: the three wrap hooks alone (without onEnter/onExit) are sufficient.
specificationVersion: environment-2023-09
extensions:
- WRAP_ACTIONS
- EXPR
environment:
name: WrapOnlyEnv
script:
actions:
onWrapEnter:
command: echo
args: ["{{Env.Wrapped.Name}}"]
onWrapTaskRun:
command: echo
args: ["{{WrappedAction.Command}}"]
onWrapExit:
command: echo
args: ["{{Env.Wrapped.Name}}"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# RFC 0008: each wrap hook supports the same fields as any other Action,
# including timeout. The all-or-nothing rule requires all three hooks.
specificationVersion: environment-2023-09
extensions:
- WRAP_ACTIONS
- EXPR
environment:
name: WrapWithTimeout
script:
actions:
onWrapEnter:
command: echo
args: ["{{Env.Wrapped.Name}}"]
timeout: 60
onWrapTaskRun:
command: echo
args: ["{{WrappedAction.Command}}"]
timeout: 300
onWrapExit:
command: echo
args: ["{{Env.Wrapped.Name}}"]
timeout: 60
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# RFC 0008 all-or-nothing rule: defining onWrapEnter and onWrapTaskRun
# without onWrapExit is invalid.
specificationVersion: environment-2023-09
extensions:
- WRAP_ACTIONS
- EXPR
environment:
name: MissingWrapExit
script:
actions:
onWrapEnter:
command: echo
args: ["{{Env.Wrapped.Name}}"]
onWrapTaskRun:
command: echo
args: ["{{WrappedAction.Command}}"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# RFC 0008 all-or-nothing rule: an environment that defines any wrap hook
# must define all three (onWrapEnter, onWrapTaskRun, onWrapExit). Defining
# only onWrapTaskRun is invalid.
specificationVersion: environment-2023-09
extensions:
- WRAP_ACTIONS
- EXPR
environment:
name: PartialWrap
script:
actions:
onWrapTaskRun:
command: echo
args: ["{{WrappedAction.Command}}"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# RFC 0008: WrappedAction.Environment is an empty list when no openjd_env
# was emitted in the session. Verifies the empty case doesn't trip the
# wrap script's iteration logic.
template:
specificationVersion: jobtemplate-2023-09
name: WrappedActionEnvironmentEmpty
steps:
- name: Step1
script:
actions:
onRun:
command: echo
args: ["placeholder"]

environments:
- specificationVersion: environment-2023-09
extensions:
- WRAP_ACTIONS
- EXPR
environment:
name: WrapEnv
script:
actions:
onWrapEnter:
command: sh
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.

Suggestion change this to bash as that's what we generally have on Windows as well.

args:
- "-c"
- "{{ repr_sh(WrappedAction.Command) }} {{ repr_sh(WrappedAction.Args) }}"
onWrapTaskRun:
command: sh
args:
- "-c"
# Print env count + a sentinel so we can confirm empty-list
# handling. With no openjd_env emissions, the loop body should
# not execute at all and only the sentinel appears.
- |
for e in {{ repr_sh(WrappedAction.Environment) }}; do
echo ENV_PRESENT=$e
done
echo ENV_DUMP_COMPLETE
onWrapExit:
command: sh
args:
- "-c"
- "{{ repr_sh(WrappedAction.Command) }} {{ repr_sh(WrappedAction.Args) }}"

runOn:
- posix
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.

If this isn't posix-specific, better to run on all OS.


expected:
output:
- ENV_DUMP_COMPLETE
forbidden:
- ENV_PRESENT=
Loading
Loading