diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 075cf5b2c8..d72d17b8f4 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -220,6 +220,9 @@ jobs: cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check cargo-lockfiles: name: Cargo lockfiles @@ -239,6 +242,10 @@ jobs: shell: nix develop -c bash -euo pipefail {0} run: tasks/scripts/check-cargo-lockfiles.sh + - name: Verify Cargo lockfile alignment + shell: nix develop -c bash -euo pipefail {0} + run: uv run tasks/scripts/check_cargo_lockfile_alignment.py + rust-lint: name: Rust lint (${{ matrix.system }}) needs: pr_metadata @@ -275,6 +282,9 @@ jobs: cargo clippy --locked --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) @@ -312,6 +322,9 @@ jobs: run: | cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/testkit/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/runner/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/tmachine/Cargo.toml rust-postgres-test: name: Rust PostgreSQL tests diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index db4c96b936..c1620adfce 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -240,25 +240,25 @@ jobs: deb-artifact-name: deb-linux-amd64 rpm-artifact-name: rpm-linux-x86_64 - # Run driver-independent conformance tests. + # Run the conformance suites the cpu-tests plan selects, one job per amd64 + # environment. conformance-integration: needs: prepare-integration permissions: actions: read contents: read packages: read - uses: ./.github/workflows/integration-runner.yml + uses: ./.github/workflows/e2e-suites.yml with: - category: conformance source-sha: ${{ needs.prepare-integration.outputs.source_sha }} integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - test-matrix: >- + matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.docker-rootful.deb"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s-ha"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootful.rpm"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootless.rpm"} ] # Run feature-specific integration tests: diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml new file mode 100644 index 0000000000..973fa6c108 --- /dev/null +++ b/.github/workflows/e2e-suites.yml @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: E2E Suites + +on: + workflow_call: + inputs: + source-sha: + description: Source revision of the prepared candidate artifacts + required: true + type: string + integration-inputs-artifact-id: + description: Immutable ID of the prepared integration inputs artifact + required: true + type: string + matrix: + description: >- + JSON array of {"plan", "runner"} entries, each with an optional + "environment". A plan is a file stem in tests/plans/ that selects + the suites and environments to run; "environment" narrows an entry + to one of them, so environments run as parallel jobs. + required: true + type: string + +permissions: + actions: read + contents: read + packages: read + +# github.workflow is the caller's name here, so scope the group to this job +# to avoid colliding with (and cancelling) the calling workflow. +concurrency: + group: ${{ github.workflow }}-e2e-suites-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-suites: + name: E2E suites (${{ matrix.plan }}, ${{ matrix.environment || matrix.runner }}) + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['source-sha'] }} + persist-credentials: false + + - name: Enable KVM access + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The runner did not expose /dev/kvm" + exit 1 + fi + sudo chmod 0666 /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - uses: ./.github/actions/setup-nix + + - name: Cache tmachine disks + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.cache/tmachine + key: tmachine-${{ runner.os }}-${{ runner.arch }} + + - name: Download integration inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs['integration-inputs-artifact-id'] }} + path: artifacts + + - name: Run suites + env: + PLAN: ${{ matrix.plan }} + ENVIRONMENT: ${{ matrix.environment || '' }} + run: | + # Retry dependency preparation, then run the plan once. The runner + # builds the suites for the guest, so it needs no prepared archives. + nix build --no-link .#test-runner || nix build --no-link .#test-runner + args=(test --plan "tests/plans/${PLAN}.yaml" --results artifacts/test-results) + if [[ -n "${ENVIRONMENT}" ]]; then + args+=(--env "${ENVIRONMENT}") + fi + nix run .#test-runner -- "${args[@]}" + + - name: Upload results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: e2e-results-${{ matrix.plan }}-${{ matrix.environment || matrix.runner }} + path: artifacts/test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index aec77972ee..82d5edebf9 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -112,24 +112,25 @@ jobs: deb-artifact-name: deb-linux-amd64 rpm-artifact-name: rpm-linux-x86_64 + # Run the conformance suites the cpu-tests plan selects, one job per amd64 + # environment. conformance-integration: needs: prepare-integration permissions: actions: read contents: read packages: read - uses: ./.github/workflows/integration-runner.yml + uses: ./.github/workflows/e2e-suites.yml with: - category: conformance source-sha: ${{ needs.prepare-integration.outputs.source_sha }} integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - test-matrix: >- + matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.docker-rootful.deb"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s-ha"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootful.rpm"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootless.rpm"} ] feature-specific-integration: diff --git a/.github/workflows/release-qualification.yml b/.github/workflows/release-qualification.yml index 3dc0ca3c9e..ab681168b4 100644 --- a/.github/workflows/release-qualification.yml +++ b/.github/workflows/release-qualification.yml @@ -65,24 +65,25 @@ jobs: deb-artifact-name: deb-linux-amd64 rpm-artifact-name: rpm-linux-x86_64 + # Run the conformance suites the cpu-tests plan selects, one job per amd64 + # environment. conformance-integration: needs: prepare-integration permissions: actions: read contents: read packages: read - uses: ./.github/workflows/integration-runner.yml + uses: ./.github/workflows/e2e-suites.yml with: - category: conformance source-sha: ${{ needs.prepare-integration.outputs.source_sha }} integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - test-matrix: >- + matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.docker-rootful.deb"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s-ha"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootful.rpm"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootless.rpm"} ] feature-specific-integration: diff --git a/AGENTS.md b/AGENTS.md index 0a21595b85..b1d809fcee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,10 @@ ocsf_emit!(event); - If you change sandbox infrastructure, ensure the relevant sandbox e2e path succeeds. +## End-to-End Tests + +- Add new end-to-end tests under `tests/suites/`, following [TESTING.md](TESTING.md). `e2e/` is being migrated to `tests/` and only takes fixes. + ## Network Sockets - On latency-sensitive TCP streams, disable Nagle's algorithm so small diff --git a/CI.md b/CI.md index b886bd70e3..fe7f535933 100644 --- a/CI.md +++ b/CI.md @@ -156,6 +156,17 @@ retain the binary installer because their fixtures configure its system service, local HTTP gateway, and CLI path. The manual Integration Tests workflow defaults to the package installers and downloads the packages selected by its matrix. +The conformance lanes (`conformance-integration`) run the e2e suites through +`e2e-suites.yml` on the same prepared inputs. Each matrix entry pairs a plan +from `tests/plans/`, which selects the suites and environments, with a runner, +and optionally narrows it to one environment so environments run as parallel +jobs. The test runner builds the suites for the guest itself, and each entry +uploads JUnit reports and failure diagnostics as +`e2e-results--`. Branch E2E, Release Dev, and Release +Qualification run the `cpu-tests` plan on the five amd64 environments: Ubuntu +with Docker (deb), K3s and HA K3s, and Fedora with rootful and rootless Podman +(rpm). + Three opt-in labels enable the long-running E2E suites: - `test:e2e` runs the Docker, rootless Podman, Kubernetes, and VM E2E suites @@ -554,6 +565,7 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma |---|---| | `.github/workflows/branch-checks.yml` | Required non-E2E checks. Triggers on `push: pull-request/[0-9]+` for PR mirrors and `merge_group` for queued merges. | | `.github/workflows/branch-e2e.yml` | Standard, GPU, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups do not run E2E. | +| `.github/workflows/e2e-suites.yml` | Reusable matrix job that runs the e2e suites selected by plans in `tests/plans/` on the given runners, optionally one environment per job, and uploads `e2e-results--`. Used by the conformance lanes. | | `.github/workflows/build-binaries.yml`, `build-vm-driver.yml` | Shared binary matrices used by branch and release workflows. The VM driver remains separate because its build consumes the runtime binaries. | | `.github/workflows/build-images.yml` | Builds and pushes multi-platform images, then uploads the same OCI images as workflow artifacts. | | `.github/workflows/package-release-binaries.yml` | Packages raw build artifacts into release tarballs without rebuilding them. | diff --git a/TESTING.md b/TESTING.md index 771b602103..6ad080b83c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,8 +16,15 @@ crates/*/tests/ # Rust integration tests python/openshell/ # Python unit tests (*_test.py suffix) e2e/python/ # Python E2E tests (test_*.py prefix) e2e/rust/ # Rust CLI E2E tests +tests/suites/ # End-to-end suites, run by test plans +tests/testkit/ # Shared support for the suites +tests/runner/ # Test plan runner, with tests/{plans,environments} +tests/tmachine/ # VM environments, provisioned with tests/ansible ``` +New end-to-end tests go under `tests/`. The suites in `e2e/` are being +migrated there; `e2e/` only takes fixes until it is empty. + ## Rust Tests Unit tests live inline with `#[cfg(test)] mod tests` blocks. Integration tests @@ -48,6 +55,17 @@ mise run test:rust # cargo test --workspace Rust validation checks tracked Cargo lockfiles; run `mise run rust:lockfiles:check` to check them directly. If one is stale, refresh it with Cargo using its adjacent manifest, review the diff, and commit the update. +Workspaces that build root crates through path dependencies keep their own +lockfiles, which `--locked` does not compare with the root's. The check also +requires the workspaces listed in +`tasks/scripts/check_cargo_lockfile_alignment.py` to lock shared crates at the +root's versions. If it fails, re-lock them from the root lockfile and commit the +result: + +```shell +uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix +``` + ### PostgreSQL-backed tests Tests that need a real PostgreSQL server, such as advisory-lock concurrency @@ -387,6 +405,137 @@ Secrets and Vault storage backends one at a time: mise run e2e:kubernetes:credential-drivers ``` +### Test plans (`tests/plans/`) + +A test plan selects suites and the environments to run them in. The runner +(`tests/runner`) builds each family's suites for the guest, boots each +environment in a tmachine VM, runs the suites inside it, and copies the +results back: + +```shell +nix run .#build-artifacts +nix run .#test-runner -- test --plan tests/plans/cpu-tests.yaml +``` + +```yaml +matrix: + - type: conformance # family: a workspace under tests/suites/ + suites: [sandbox-lifecycle] # omit to run every suite in the family + environments: [tmachine.ubuntu2404-arm64.docker-rootful.deb] +``` + +Add `--dry-run` to print the resolved jobs and nextest filtersets without +building or booting anything, `--env` or `--suite` to narrow a plan, and +`--keep` to leave each VM running after its tests. Results, including JUnit +reports and gateway logs for failures, go to +`artifacts/test-results////`. + +Before running an environment's suites, the runner waits up to five minutes for +its gateway to report healthy and for the environment's CLI to reach it +(`testkit-ready`, shipped in each bundle). Failing either is reported as an +environment error, not as test failures. + +Environments are defined in `tests/environments/*.yaml`. tmachine boots guests +of the host's architecture only, so the runner reports environments for other +architectures as unsupported instead of failing. + +Nobody lists which tests run where. A test is expected in every environment +whose capabilities include everything the test `requires`; plans only choose +which of those to run. To see where each test runs, or why it doesn't, run: + +```shell +nix run .#test-runner -- matrix # add --host to apply this machine's limits +``` + +`matrix` and `test` fail if a test runs in no defined environment, or if tests +require a capability that no environment declares. When you add a capability, +declare it in every environment that provides it in the same change. If you +are unsure whether an environment provides it, declare it: a wrong claim shows +up as a test failure, while a wrong omission silently drops coverage. + +To skip a test where it fails because of a known bug, add a waiver to +`tests/waivers.yaml` with the test's ID, an environment selector, the issue +tracking the bug, and a reason: + +```yaml +waivers: + - test: t-3tfdx240 + environments: { runtime: podman-rootless } # fields of the environment; omit for all + issue: https://github.com/NVIDIA/OpenShell/issues/NNNN + reason: what fails, and where +``` + +Waived tests are skipped in every plan and listed in the run summary. Run with +`--ignore-waivers` to check whether a fix landed, and remove the waiver when +it has. Use waivers only for bugs: a feature an environment lacks is a missing +capability, and running less on a given occasion is a choice of plan. + +Conformance suites (`tests/suites/conformance/`) are black-box tests of the +public client interfaces: the gateway API through the Rust SDK, and the +`openshell` CLI the environment installed. The suites are `sandbox-lifecycle`, +`file-transfer`, and `policy-advisor`. Each suite is one test binary of +`#[testkit::test]` functions. `testkit` (`tests/testkit/`) is the support +library shared by every family: it reads the target, connects to the gateway, +creates and cleans up sandboxes, and provides the attribute. The runner parses +tests with the same `testkit-spec` crate as the attribute. + +```rust +/// A long-running sandbox becomes ready, runs a command with exec, and is gone +/// from get and list after delete. +#[testkit::test(id = "t-3tfdx240")] +async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { + // ... +} +``` + +Every test needs a stable `id`: `t-` followed by eight random lowercase letters +or digits. Results, waivers, and coverage records refer to tests by this ID, so +never change or reuse it, including when you rename the test or move it to +another suite or family. Omit `id` when you write a new test; the compile error +suggests a fresh one. IDs must be unique across all families, which the runner +checks, and the runner adds each test's ID to its JUnit test case as the +`test_id` property. + +A conformance test asserts only what the public contract promises: the +comments in `proto/` and the published docs, such as the sandbox lifecycle in +`docs/how-it-works/sandboxes/overview.mdx`. Do not encode current gateway +behavior the contract doesn't state; file an issue to document it instead. +Assert on a sandbox's `phase` and `exit_code` rather than condition reasons, +which the API leaves to the platform. Check that something did not happen +(for example, `restart_count` is 0 and a second read shows the same phase) +instead of sleeping and watching. Behavior the docs leave to the compute +driver needs a capability in `requires`. + +Act through the interface that makes the promise. Upload, download, connect, +and port forwarding exist only in the CLI, so those tests call `cx.cli([...])` +and assert through the SDK where they can; rely on the CLI's exit code and +`--output json`, not its table text. For API calls the SDK does not wrap yet, +such as settings and draft policy, add a testkit helper over the generated gRPC +client rather than calling it from tests. + +Every environment provides its CLI: the installer's `openshell_client` role +writes a `cli:` section (binary and gateway name) next to `gateway:` in the +target, and `testkit-ready` checks that `openshell status` reports the gateway +connected. The CLI is part of the target, not a capability, so CLI-driven tests +run everywhere. + +The function name is the nextest test name and is what nextest filters +select. Rename it when a clearer name helps; the ID keeps the history. +The doc comment is the required description. Declare capabilities a test needs with +`#[testkit::test(requires = [service_exposure])]`; the runner excludes the test +in environments without them and reports it as unsupported. Tests read the +system under test from the target file named by `OPENSHELL_TEST_TARGET`, so you +can also run a suite directly against a gateway you already have: + +```shell +OPENSHELL_TEST_TARGET=target.yaml cargo nextest run \ + --manifest-path tests/suites/conformance/Cargo.toml \ + -p openshell-conformance-sandbox-lifecycle +``` + +`cargo run --manifest-path tests/runner/Cargo.toml --bin openshell-test -- catalog` +prints every suite and test with its description and requirements. + ### Kubernetes E2E (`e2e/rust/e2e-kubernetes.sh`) Kubernetes e2e tests deploy an OpenShell gateway into a real Kubernetes cluster diff --git a/flake.nix b/flake.nix index 21b2e1b091..f1ab2d3a62 100644 --- a/flake.nix +++ b/flake.nix @@ -127,7 +127,7 @@ firmwarePkgs = testGuestPkgs; }; testMachines = import ./tests/config.nix { - inherit pkgs toolchains; + inherit pkgs toolchains rustToolchain; qemuPkgs = tmachineRuntimePkgs; firmwarePkgs = tmachineRuntimePkgs; }; @@ -199,6 +199,7 @@ tmachine = testMachines.package; tmachine-config = testMachines.config; tmachine-unwrapped = testMachines.unwrapped; + test-runner = testMachines.testRunner; }; devShells = { diff --git a/tasks/rust.toml b/tasks/rust.toml index 32928b8f69..d7b4675265 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -18,8 +18,11 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts hide = true ["rust:lockfiles:check"] -description = "Verify all tracked Cargo lockfiles are current" -run = "tasks/scripts/check-cargo-lockfiles.sh" +description = "Verify all tracked Cargo lockfiles are current and aligned with the root" +run = [ + "tasks/scripts/check-cargo-lockfiles.sh", + "uv run tasks/scripts/check_cargo_lockfile_alignment.py", +] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/check-cargo-lockfiles.ps1" hide = true @@ -32,6 +35,9 @@ run = [ "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -43,6 +49,9 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", + "cargo fmt --manifest-path tests/testkit/Cargo.toml --all", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all", ] hide = true @@ -53,6 +62,9 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py new file mode 100644 index 0000000000..a900cc345f --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.11" +# /// + +"""Check that aligned Cargo workspaces lock shared crates like the root does. + +Some workspaces outside the root build root crates through path +dependencies, for example the conformance suites build `openshell-sdk`. Each +workspace has its own Cargo.lock, and `cargo --locked` only checks a lockfile +against its own manifest, so nothing stops the two lockfiles from resolving +the same crate to different versions. The root's versions are the ones CI and +the Nix toolchain are built for; a newer `aws-lc-sys`, for example, does not +link against the AWS-LC that Nix provides. + +This check fails when an aligned workspace locks a registry crate that the +root lockfile also contains at a version the root lockfile does not have. +With `--fix`, it re-locks each aligned workspace starting from the root +lockfile. +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +# Workspaces whose shared crates must match the root lockfile. Add a +# workspace here once its lockfile has been aligned with `--fix`. +ALIGNED_WORKSPACES = ("tests/suites/conformance", "tests/testkit") + + +@dataclass(frozen=True) +class Mismatch: + workspace: str + name: str + version: str + root_versions: tuple[str, ...] + + def target(self) -> str | None: + """The root version semver-compatible with the locked one, if any.""" + key = compat_key(self.version) + candidates = [v for v in self.root_versions if compat_key(v) == key] + return max(candidates, key=version_tuple) if candidates else None + + +def version_tuple(version: str) -> tuple[int, ...]: + core = re.split(r"[-+]", version, maxsplit=1)[0] + return tuple(int(part) for part in core.split(".")) + + +def compat_key(version: str) -> tuple[int, ...]: + """Cargo's caret-compatibility class: the leftmost non-zero component.""" + major, minor, patch = (*version_tuple(version), 0, 0, 0)[:3] + if major: + return (major,) + if minor: + return (0, minor) + return (0, 0, patch) + + +def registry_versions(lockfile: Path) -> dict[str, set[str]]: + """Versions of registry (non-path) packages in a lockfile, by name.""" + data = tomllib.loads(lockfile.read_text()) + versions: dict[str, set[str]] = {} + for package in data.get("package", []): + if "source" in package: + versions.setdefault(package["name"], set()).add(package["version"]) + return versions + + +def mismatches(root: Path, workspace: str) -> list[Mismatch]: + root_versions = registry_versions(root / "Cargo.lock") + found = [] + for name, versions in sorted( + registry_versions(root / workspace / "Cargo.lock").items() + ): + shared = root_versions.get(name) + if shared is None: + continue + for version in sorted(versions - shared, key=version_tuple): + found.append( + Mismatch( + workspace, name, version, tuple(sorted(shared, key=version_tuple)) + ) + ) + return found + + +def fix(root: Path, workspace: str) -> list[Mismatch]: + """Re-lock the workspace starting from the root lockfile. + + Coupled crates such as the `futures-*` family pin each other, so they + cannot be moved one at a time. Seeding the workspace lockfile with the + root's lets Cargo keep every root version that still fits, add only the + crates the root does not have, and drop the rest. Returns what is still + misaligned afterwards. + """ + shutil.copyfile(root / "Cargo.lock", root / workspace / "Cargo.lock") + subprocess.run( + [ + "cargo", + "metadata", + "--format-version", + "1", + "--manifest-path", + str(root / workspace / "Cargo.toml"), + ], + check=True, + stdout=subprocess.DEVNULL, + ) + return mismatches(root, workspace) + + +def report(problems: list[Mismatch]) -> None: + for m in problems: + target = m.target() + hint = ( + f"; root has {target}" + if target + else f"; root has {', '.join(m.root_versions)}" + ) + print( + f"error: {m.workspace}: {m.name} {m.version} is not in the root Cargo.lock{hint}", + file=sys.stderr, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--fix", action="store_true", help="update aligned lockfiles to root versions" + ) + args = parser.parse_args(argv) + + root = Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + problems: list[Mismatch] = [] + for workspace in ALIGNED_WORKSPACES: + print(f"Checking {workspace}/Cargo.lock against Cargo.lock") + problems += fix(root, workspace) if args.fix else mismatches(root, workspace) + + if problems: + report(problems) + if not args.fix: + print( + "Run `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix` to align them.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/scripts/check_cargo_lockfile_alignment_test.py b/tasks/scripts/check_cargo_lockfile_alignment_test.py new file mode 100644 index 0000000000..955f31db9e --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment_test.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Cargo lockfile alignment check.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location( + "check_cargo_lockfile_alignment", + Path(__file__).with_name("check_cargo_lockfile_alignment.py"), +) +assert _SPEC and _SPEC.loader +alignment = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = alignment +_SPEC.loader.exec_module(alignment) + +REGISTRY = 'source = "registry+https://github.com/rust-lang/crates.io-index"' + + +def lockfile(path: Path, packages: list[tuple[str, str, bool]]) -> None: + """Write a lockfile of (name, version, from_registry) packages.""" + entries = [] + for name, version, from_registry in packages: + entry = f'[[package]]\nname = "{name}"\nversion = "{version}"\n' + if from_registry: + entry += REGISTRY + "\n" + entries.append(entry) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("version = 4\n\n" + "\n".join(entries)) + + +def test_compat_key_follows_cargo_caret_rules() -> None: + assert alignment.compat_key("1.52.1") == (1,) + assert alignment.compat_key("0.44.0") == (0, 44) + assert alignment.compat_key("0.0.3") == (0, 0, 3) + assert alignment.compat_key("1.0.4+wasi-0.2.12") == (1,) + + +def test_reports_shared_registry_crates_at_other_versions(tmp_path: Path) -> None: + lockfile( + tmp_path / "Cargo.lock", + [ + ("tokio", "1.52.1", True), + ("rand", "0.8.6", True), + ("rand", "0.9.4", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + lockfile( + tmp_path / "ws" / "Cargo.lock", + [ + ("tokio", "1.53.1", True), + ("rand", "0.9.5", True), + ("rand", "0.8.6", True), + ("only-here", "2.0.0", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + + found = alignment.mismatches(tmp_path, "ws") + + assert [(m.name, m.version, m.target()) for m in found] == [ + ("rand", "0.9.5", "0.9.4"), + ("tokio", "1.53.1", "1.52.1"), + ] + + +def test_no_target_without_a_compatible_root_version(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("syn", "1.0.109", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("syn", "2.0.119", True)]) + + (found,) = alignment.mismatches(tmp_path, "ws") + + assert found.target() is None + + +def test_aligned_lockfiles_pass(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("tokio", "1.52.1", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("tokio", "1.52.1", True)]) + + assert alignment.mismatches(tmp_path, "ws") == [] diff --git a/tasks/test.toml b/tasks/test.toml index c1df20262a..fc31db272b 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -23,6 +23,7 @@ depends = [ "test:docs-website", "test:docs-nav", "test:maintainer-approval", + "test:cargo-lockfile-alignment", ] ["test:docs-website"] @@ -31,6 +32,10 @@ description = "Test the docs-website sync script" # dependencies and the script's runtime dependency, which live outside the project env. run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" +["test:cargo-lockfile-alignment"] +description = "Test the Cargo lockfile alignment check" +run = "uv run --no-project --with pytest pytest tasks/scripts/check_cargo_lockfile_alignment_test.py" + ["test:docs-nav"] description = "Test the docs navigation check" run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/check_docs_nav_test.py" @@ -117,6 +122,9 @@ run = [ "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/testkit/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/runner/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/tmachine/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true diff --git a/tests/ansible/playbooks/nextest.yaml b/tests/ansible/playbooks/nextest.yaml index 6813d5b96f..3c7c50d678 100644 --- a/tests/ansible/playbooks/nextest.yaml +++ b/tests/ansible/playbooks/nextest.yaml @@ -50,3 +50,11 @@ - name: Verify cargo-nextest ansible.builtin.command: cargo-nextest --version changed_when: false + + # The CLI's upload runs git to apply .gitignore rules, and the + # file-transfer tests create Git work trees. Fedora cloud images lack git. + - name: Install test dependencies + ansible.builtin.package: + name: + - git + state: present diff --git a/tests/ansible/roles/openshell_client/defaults/main.yaml b/tests/ansible/roles/openshell_client/defaults/main.yaml new file mode 100644 index 0000000000..8e90954947 --- /dev/null +++ b/tests/ansible/roles/openshell_client/defaults/main.yaml @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +openshell_client_config_dir: >- + /home/{{ ansible_user }}/.config/openshell/gateways/{{ openshell_client_gateway_name | default('tmachine') }} diff --git a/tests/ansible/roles/openshell_client/tasks/main.yaml b/tests/ansible/roles/openshell_client/tasks/main.yaml index 6586f23ac2..df7319eed6 100644 --- a/tests/ansible/roles/openshell_client/tasks/main.yaml +++ b/tests/ansible/roles/openshell_client/tasks/main.yaml @@ -12,3 +12,48 @@ - --local - --name - "{{ openshell_client_gateway_name | default('tmachine') }}" + +# Describe the registered gateway for conformance tests. The test runner +# combines this with the environment's capabilities into the target file +# that tests read through OPENSHELL_TEST_TARGET. +- name: Read registered gateway metadata + ansible.builtin.slurp: + src: "{{ openshell_client_config_dir }}/metadata.json" + register: openshell_client_metadata + +- name: Check for registered mTLS client credentials + ansible.builtin.stat: + path: "{{ openshell_client_config_dir }}/mtls/tls.crt" + register: openshell_client_mtls + +- name: Find the installed OpenShell CLI + ansible.builtin.shell: command -v openshell + changed_when: false + register: openshell_client_binary + +- name: Create test target directory + become: true + ansible.builtin.file: + path: /etc/openshell-test + state: directory + mode: "0755" + +- name: Write gateway and CLI sections of the test target + become: true + ansible.builtin.copy: + dest: /etc/openshell-test/gateway.yaml + mode: "0644" + content: | + # Written by the openshell_client role. + {{ {'gateway': openshell_client_target_gateway, 'cli': openshell_client_target_cli} | to_nice_yaml(indent=2) }} + vars: + openshell_client_target_cli: + binary: "{{ openshell_client_binary.stdout | trim }}" + gateway: "{{ openshell_client_gateway_name | default('tmachine') }}" + openshell_client_target_tls: + ca: "{{ openshell_client_config_dir }}/mtls/ca.crt" + client_cert: "{{ openshell_client_config_dir }}/mtls/tls.crt" + client_key: "{{ openshell_client_config_dir }}/mtls/tls.key" + openshell_client_target_gateway: >- + {{ {'endpoint': (openshell_client_metadata.content | b64decode | from_json).gateway_endpoint} + | combine({'tls': openshell_client_target_tls} if openshell_client_mtls.stat.exists else {}) }} diff --git a/tests/artifacts.nix b/tests/artifacts.nix index ef5e26988c..d7187cb7a6 100644 --- a/tests/artifacts.nix +++ b/tests/artifacts.nix @@ -58,6 +58,7 @@ let mkdir -p "$(dirname "$output")" cd "$root" cargo nextest archive \ + --locked \ --manifest-path "$manifest_path" \ --target ${target} \ -p ${package} \ diff --git a/tests/config.nix b/tests/config.nix index a5318cb875..f8fe19578a 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -4,6 +4,7 @@ { pkgs, toolchains, + rustToolchain, qemuPkgs ? pkgs, firmwarePkgs ? pkgs, }: @@ -232,9 +233,38 @@ let exec ${tmachine}/bin/tmachine --config ${config} "$@" ''; }; + # Runs test plans with tests/runner. It cross-builds guest test bundles and + # drives tmachine sessions, so it needs the toolchains and the VM tools. + testRunner = pkgs.writeShellApplication { + name = "test-runner"; + runtimeInputs = [ + qemu + pkgs.ansible + pkgs.cargo-nextest + pkgs.coreutils + pkgs.git + pkgs.gnutar + pkgs.sshpass + rustToolchain + ]; + runtimeEnv = + pkgs.lib.foldl' (env: toolchain: env // toolchain.env) { } (builtins.attrValues toolchains) + // { + TMACHINE_CONFIG = "${config}"; + } + // pkgs.lib.optionalAttrs isAarch64 { + TMACHINE_FIRMWARE_CODE = firmwarePkgs.OVMF.firmware; + TMACHINE_FIRMWARE_VARS = firmwarePkgs.OVMF.variables; + }; + text = '' + root=$(git rev-parse --show-toplevel) + export ANSIBLE_CONFIG="$root/tests/ansible/ansible.cfg" + exec cargo run --quiet --locked --manifest-path "$root/tests/runner/Cargo.toml" --bin openshell-test -- "$@" + ''; + }; in { package = runner; unwrapped = tmachine; - inherit config; + inherit config testRunner; } diff --git a/tests/environments/docker.yaml b/tests/environments/docker.yaml new file mode 100644 index 0000000000..e7259566a7 --- /dev/null +++ b/tests/environments/docker.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Docker-backed environments. The ID must equal +# .-... +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + capabilities: { compute_driver: docker, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + capabilities: { compute_driver: docker, rootless: false, gpu: false, persistent_workspace: true } diff --git a/tests/environments/k3s.yaml b/tests/environments/k3s.yaml new file mode 100644 index 0000000000..cb753e4339 --- /dev/null +++ b/tests/environments/k3s.yaml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +environments: + - id: tmachine.ubuntu2404-amd64.k3s.k3s + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s + tmachine: { environment: ubuntu-k3s, installer: k3s } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.k3s.k3s + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: k3s + installer: k3s + tmachine: { environment: ubuntu-k3s, installer: k3s } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-amd64.k3s.k3s-ha + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s-ha + tmachine: { environment: ubuntu-k3s, installer: k3s-ha } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.k3s.k3s-ha + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: k3s + installer: k3s-ha + tmachine: { environment: ubuntu-k3s, installer: k3s-ha } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } diff --git a/tests/environments/podman.yaml b/tests/environments/podman.yaml new file mode 100644 index 0000000000..e87f648350 --- /dev/null +++ b/tests/environments/podman.yaml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +environments: + - id: tmachine.fedora44-amd64.podman-rootful.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootful + installer: rpm + tmachine: { environment: fedora-podman-rootful, installer: rpm } + capabilities: { compute_driver: podman, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-arm64.podman-rootful.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: arm64 } + runtime: podman-rootful + installer: rpm + tmachine: { environment: fedora-podman-rootful, installer: rpm } + capabilities: { compute_driver: podman, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-amd64.podman-rootless.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootless + installer: rpm + tmachine: { environment: fedora-podman-rootless, installer: rpm } + capabilities: { compute_driver: podman, rootless: true, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-arm64.podman-rootless.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: arm64 } + runtime: podman-rootless + installer: rpm + tmachine: { environment: fedora-podman-rootless, installer: rpm } + capabilities: { compute_driver: podman, rootless: true, gpu: false, persistent_workspace: true } diff --git a/tests/plans/cpu-tests.yaml b/tests/plans/cpu-tests.yaml new file mode 100644 index 0000000000..a620812f05 --- /dev/null +++ b/tests/plans/cpu-tests.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +matrix: + - type: conformance + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb + - tmachine.ubuntu2404-arm64.docker-rootful.deb + - tmachine.ubuntu2404-amd64.k3s.k3s + - tmachine.ubuntu2404-arm64.k3s.k3s + - tmachine.ubuntu2404-amd64.k3s.k3s-ha + - tmachine.ubuntu2404-arm64.k3s.k3s-ha + - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-arm64.podman-rootful.rpm + - tmachine.fedora44-amd64.podman-rootless.rpm + - tmachine.fedora44-arm64.podman-rootless.rpm diff --git a/tests/runner/Cargo.lock b/tests/runner/Cargo.lock new file mode 100644 index 0000000000..4e7794e7a3 --- /dev/null +++ b/tests/runner/Cargo.lock @@ -0,0 +1,814 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openshell-test-runner" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "noyalib", + "openshell-testkit-spec", + "serde", + "serde_json", + "tempfile", + "tmachine", + "tokio", + "toml", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "encoding_rs_io", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tmachine" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "clap", + "directories", + "serde", + "serde-saphyr", + "tempfile", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml new file mode 100644 index 0000000000..a3df622b2b --- /dev/null +++ b/tests/runner/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-runner" +description = "Runs OpenShell test plans against provisioned environments" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[[bin]] +name = "openshell-test" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +openshell-testkit-spec = { path = "../testkit/testkit-spec" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } +tempfile = "3" +tmachine = { path = "../tmachine" } +tokio = { version = "1", features = ["macros", "rt"] } +toml = "0.9" + +[workspace] diff --git a/tests/runner/src/bundle.rs b/tests/runner/src/bundle.rs new file mode 100644 index 0000000000..c7d8b3b4a3 --- /dev/null +++ b/tests/runner/src/bundle.rs @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Building a family's test bundle for the guest. +//! +//! A bundle is a tar holding a nextest archive built for the guest target, +//! the family workspace's `Cargo.toml` files (for `--workspace-remap`), a +//! `nextest` config with the runner's `JUnit` profile, and `testkit-ready`, +//! which waits for the gateway before the tests run. The guest needs only the +//! `cargo-nextest` binary to run it. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; +use tempfile::TempDir; + +use crate::catalog::Family; +use crate::environments::Arch; + +/// The nextest profile the runner uses in the guest. +pub const PROFILE: &str = "openshell"; + +/// The readiness check every bundle carries, at the bundle root. +pub const READY_BINARY: &str = "testkit-ready"; + +const NEXTEST_CONFIG: &str = r#"# Written by the OpenShell test runner. +[profile.openshell] +fail-fast = false + +[profile.openshell.junit] +path = "junit.xml" +"#; + +pub struct Bundle { + pub path: PathBuf, + _dir: TempDir, +} + +/// The Rust target for guests of `arch`. Static musl binaries don't depend +/// on the guest's libc. +pub const fn guest_target(arch: Arch) -> &'static str { + match arch { + Arch::Amd64 => "x86_64-unknown-linux-musl", + Arch::Arm64 => "aarch64-unknown-linux-musl", + } +} + +/// Build `testkit-ready` for the guest and return its path. +pub fn build_ready(root: &Path, target: &str) -> Result { + let output = Command::new("cargo") + .args(["build", "--locked", "--manifest-path"]) + .arg(root.join("tests/testkit/Cargo.toml")) + .args(["-p", "openshell-testkit", "--bin", READY_BINARY]) + .args([ + "--target", + target, + "--message-format", + "json-render-diagnostics", + ]) + .stderr(std::process::Stdio::inherit()) + .output() + .context("run cargo build")?; + ensure!( + output.status.success(), + "build {READY_BINARY} exited with {}", + output.status + ); + built_executable(&output.stdout, READY_BINARY) + .with_context(|| format!("cargo build did not report a {READY_BINARY} executable")) +} + +/// The executable cargo reports building for the binary target `name`. +fn built_executable(messages: &[u8], name: &str) -> Option { + #[derive(Deserialize)] + struct Message { + reason: String, + target: Option, + executable: Option, + } + #[derive(Deserialize)] + struct Target { + name: String, + } + + serde_json::Deserializer::from_slice(messages) + .into_iter::() + .filter_map(std::result::Result::ok) + .filter(|message| message.reason == "compiler-artifact") + .filter(|message| { + message + .target + .as_ref() + .is_some_and(|target| target.name == name) + }) + .find_map(|message| message.executable) +} + +/// Build the bundle for `packages`, and check that the archive contains a +/// binary for every suite in `binaries`. `ready` is the guest's +/// `testkit-ready`. +pub fn build( + family: &Family, + packages: &BTreeSet, + binaries: &BTreeSet, + target: &str, + ready: &Path, +) -> Result { + let dir = tempfile::tempdir().context("create bundle directory")?; + let stage = dir.path().join("stage"); + std::fs::create_dir(&stage)?; + let manifest = family.workspace.join("Cargo.toml"); + let archive = stage.join("tests.tar.zst"); + + let mut command = Command::new("cargo"); + command + .args(["nextest", "archive", "--locked", "--manifest-path"]) + .arg(&manifest) + .args(["--target", target, "--archive-file"]) + .arg(&archive); + for package in packages { + command.args(["-p", package]); + } + run(&mut command).with_context(|| format!("build {} test archive", family.name))?; + + let archived = archived_binaries(&manifest, &archive)?; + let missing: Vec<_> = binaries.difference(&archived).collect(); + ensure!( + missing.is_empty(), + "the {} catalog lists suites the archive has no test binary for: {missing:?}; archived: {archived:?}", + family.name + ); + + copy_manifests(&family.workspace, &stage)?; + std::fs::write(stage.join("nextest.toml"), NEXTEST_CONFIG)?; + std::fs::copy(ready, stage.join(READY_BINARY)) + .with_context(|| format!("copy {}", ready.display()))?; + + let path = dir.path().join(format!("{}-tests.tar", family.name)); + run(Command::new("tar") + .arg("-C") + .arg(&stage) + .arg("-cf") + .arg(&path) + .arg(".")) + .context("pack test bundle")?; + Ok(Bundle { path, _dir: dir }) +} + +#[derive(Deserialize)] +struct ListOutput { + #[serde(rename = "rust-binaries")] + rust_binaries: std::collections::BTreeMap, +} + +#[derive(Deserialize)] +struct ListSuite { + #[serde(rename = "binary-name")] + binary_name: String, + kind: String, +} + +/// Names of the test binaries in an archive, read without executing them. +fn archived_binaries(manifest: &Path, archive: &Path) -> Result> { + let output = Command::new("cargo") + .args(["nextest", "list", "--manifest-path"]) + .arg(manifest) + .arg("--archive-file") + .arg(archive) + .args(["--list-type", "binaries-only", "--message-format", "json"]) + .output() + .context("run cargo nextest list")?; + ensure!( + output.status.success(), + "cargo nextest list exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let list: ListOutput = + serde_json::from_slice(&output.stdout).context("parse cargo nextest list output")?; + Ok(list + .rust_binaries + .into_values() + .filter(|suite| suite.kind == "test") + .map(|suite| suite.binary_name) + .collect()) +} + +/// Copy every `Cargo.toml` under `workspace` into `stage`, keeping relative +/// paths and skipping build output. +fn copy_manifests(workspace: &Path, stage: &Path) -> Result<()> { + fn walk(root: &Path, dir: &Path, stage: &Path) -> Result<()> { + for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { + let path = entry?.path(); + let name = path.file_name().unwrap_or_default(); + if path.is_dir() { + if name != "target" && name != ".git" { + walk(root, &path, stage)?; + } + } else if name == "Cargo.toml" { + let dest = stage.join(path.strip_prefix(root)?); + std::fs::create_dir_all(dest.parent().unwrap_or(stage))?; + std::fs::copy(&path, &dest).with_context(|| format!("copy {}", path.display()))?; + } + } + Ok(()) + } + walk(workspace, workspace, stage) +} + +fn run(command: &mut Command) -> Result<()> { + let status = command + .status() + .with_context(|| format!("run {command:?}"))?; + if !status.success() { + bail!("{command:?} exited with {status}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_executable_finds_the_named_binary() { + let messages = br#"{"reason":"compiler-artifact","target":{"name":"testkit"},"executable":null} +{"reason":"compiler-artifact","target":{"name":"testkit-ready"},"executable":"/t/debug/testkit-ready"} +{"reason":"build-finished","success":true} +"#; + assert_eq!( + built_executable(messages, "testkit-ready"), + Some(PathBuf::from("/t/debug/testkit-ready")) + ); + assert_eq!(built_executable(messages, "other"), None); + } +} diff --git a/tests/runner/src/catalog.rs b/tests/runner/src/catalog.rs new file mode 100644 index 0000000000..3e244d039c --- /dev/null +++ b/tests/runner/src/catalog.rs @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The test catalog: every family, suite, and test, read from source. +//! +//! A family is a Cargo workspace under `tests/suites/`. A suite is one test +//! binary (`/tests/.rs`) containing `#[testkit::test]` +//! functions; the suite name is the binary name with `_` replaced by `-`. +//! Tests are parsed with `testkit-spec`, the same parser the macro uses, so +//! the runner never executes test binaries on the host to learn about them. +//! Test IDs are unique across every family, so a test can move between +//! suites and families without changing its ID. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +/// Families the runner knows how to run, and their workspaces relative to +/// the repository root. +const FAMILIES: &[(&str, &str)] = &[("conformance", "tests/suites/conformance")]; + +#[derive(Debug, Serialize)] +pub struct Family { + pub name: String, + #[serde(skip)] + pub workspace: PathBuf, + pub suites: BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Suite { + pub suite: String, + pub binary: String, + pub package: String, + pub tests: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Test { + /// The stable test ID. + pub id: String, + /// The function name, which is also the nextest test name. + pub name: String, + pub description: String, + pub requires: Vec, +} + +/// Every known family, keyed by name. +#[derive(Debug, Serialize)] +pub struct Catalog { + pub families: BTreeMap, +} + +impl Catalog { + pub fn load(root: &Path) -> Result { + let mut families = BTreeMap::new(); + for (name, workspace) in FAMILIES { + let family = Family::load(name, &root.join(workspace)) + .with_context(|| format!("load {name} test catalog"))?; + families.insert((*name).to_string(), family); + } + let catalog = Self { families }; + catalog.check_unique_ids()?; + Ok(catalog) + } + + /// Every test, with its family and suite. + pub fn tests(&self) -> impl Iterator { + self.families.values().flat_map(|family| { + family + .suites + .values() + .flat_map(move |suite| suite.tests.iter().map(move |test| (family, suite, test))) + }) + } + + /// Fail if two tests anywhere in the catalog share an ID. + fn check_unique_ids(&self) -> Result<()> { + let mut seen: BTreeMap<&str, String> = BTreeMap::new(); + for family in self.families.values() { + for suite in family.suites.values() { + for test in &suite.tests { + let location = format!("{}/{}/{}", family.name, suite.suite, test.name); + if let Some(first) = seen.insert(&test.id, location.clone()) { + bail!("test ID {} is used by both {first} and {location}", test.id); + } + } + } + } + Ok(()) + } + + pub fn family(&self, name: &str) -> Result<&Family> { + self.families.get(name).with_context(|| { + format!( + "unknown test family {name:?}; known families: {}", + self.families.keys().cloned().collect::>().join(", ") + ) + }) + } +} + +#[derive(Deserialize)] +struct WorkspaceManifest { + workspace: WorkspaceSection, +} + +#[derive(Deserialize)] +struct WorkspaceSection { + members: Vec, +} + +#[derive(Deserialize)] +struct PackageManifest { + package: PackageSection, +} + +#[derive(Deserialize)] +struct PackageSection { + name: String, +} + +impl Family { + fn load(name: &str, workspace: &Path) -> Result { + let manifest: WorkspaceManifest = read_toml(&workspace.join("Cargo.toml"))?; + let mut suites = BTreeMap::new(); + for member in manifest.workspace.members { + let member_dir = workspace.join(&member); + for suite in member_suites(&member_dir)? { + if let Some(existing) = suites.insert(suite.suite.clone(), suite) { + bail!( + "suite {:?} is defined more than once (again in {member})", + existing.suite + ); + } + } + } + Ok(Self { + name: name.to_string(), + workspace: workspace.to_path_buf(), + suites, + }) + } + + pub fn suite(&self, name: &str) -> Result<&Suite> { + self.suites.get(name).with_context(|| { + format!( + "unknown {} suite {name:?}; known suites: {}", + self.name, + self.suites.keys().cloned().collect::>().join(", ") + ) + }) + } + + /// Packages containing the given suites. + pub fn packages<'a>(&self, suites: impl IntoIterator) -> BTreeSet { + suites + .into_iter() + .filter_map(|suite| self.suites.get(suite)) + .map(|suite| suite.package.clone()) + .collect() + } +} + +/// Suites in one workspace member: its `tests/*.rs` files that contain +/// `#[testkit::test]` functions. +fn member_suites(member_dir: &Path) -> Result> { + let tests_dir = member_dir.join("tests"); + if !tests_dir.is_dir() { + return Ok(Vec::new()); + } + let package: PackageManifest = read_toml(&member_dir.join("Cargo.toml"))?; + + let mut files: Vec<_> = std::fs::read_dir(&tests_dir) + .with_context(|| format!("read {}", tests_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + files.retain(|path| path.extension().is_some_and(|ext| ext == "rs")); + files.sort(); + + let mut suites = Vec::new(); + for file in files { + let source = + std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?; + let specs = testkit_spec::scan(&source) + .map_err(|error| anyhow::anyhow!("{}: {error}", file.display()))?; + if specs.is_empty() { + continue; + } + let binary = file + .file_stem() + .and_then(|stem| stem.to_str()) + .with_context(|| format!("test file name {} is not UTF-8", file.display()))? + .to_string(); + suites.push(Suite { + suite: binary.replace('_', "-"), + binary, + package: package.package.name.clone(), + tests: specs + .into_iter() + .map(|spec| Test { + id: spec.id, + name: spec.name.to_string(), + description: spec.description, + requires: spec.requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }); + } + Ok(suites) +} + +fn read_toml(path: &Path) -> Result { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + toml::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + #[test] + fn repository_catalog_has_sandbox_lifecycle() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let family = catalog.family("conformance").unwrap(); + let suite = family.suite("sandbox-lifecycle").unwrap(); + assert_eq!(suite.binary, "sandbox_lifecycle"); + assert_eq!(suite.package, "openshell-conformance-sandbox-lifecycle"); + assert!( + suite + .tests + .iter() + .any(|test| test.name == "long_running_exec_delete" + && testkit_spec::is_valid_id(&test.id) + && !test.description.is_empty()) + ); + // CLI-driven tests in the same workspace are not testkit suites. + assert!( + family + .suites + .values() + .all(|suite| suite.package != "openshell-test-conformance-cli") + ); + } + + #[test] + fn duplicate_ids_are_rejected_across_families() { + let test = |name: &str| Test { + id: "t-00000001".to_string(), + name: name.to_string(), + description: "d".to_string(), + requires: Vec::new(), + }; + let family = |name: &str, test_name: &str| Family { + name: name.to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([( + "s".to_string(), + Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: vec![test(test_name)], + }, + )]), + }; + let catalog = Catalog { + families: BTreeMap::from([ + ("a".to_string(), family("a", "first")), + ("b".to_string(), family("b", "second")), + ]), + }; + let err = catalog.check_unique_ids().unwrap_err().to_string(); + assert!( + err.contains("a/s/first") && err.contains("b/s/second"), + "{err}" + ); + } + + #[test] + fn unknown_names_list_known_ones() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let err = catalog.family("features").unwrap_err(); + assert!(err.to_string().contains("conformance"), "{err}"); + let err = catalog + .family("conformance") + .unwrap() + .suite("sandbox-lifecyle") + .unwrap_err(); + assert!(err.to_string().contains("sandbox-lifecycle"), "{err}"); + } +} diff --git a/tests/runner/src/environments.rs b/tests/runner/src/environments.rs new file mode 100644 index 0000000000..e88234f736 --- /dev/null +++ b/tests/runner/src/environments.rs @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Environment definitions: `tests/environments/*.yaml`. +//! +//! Each entry describes one environment a plan can name: the machine, the +//! container runtime, the installer, how the backend provisions it, and the +//! capabilities it gives tests. Capabilities pass through to the target file +//! unchanged; testkit validates their names. + +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; + +/// All environments, keyed by ID. +#[derive(Debug, Default)] +pub struct Catalog { + environments: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + environments: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Environment { + pub id: String, + pub backend: Backend, + pub machine: Machine, + pub runtime: String, + pub installer: String, + /// Backend parameters when `backend` is `tmachine`. + #[serde(default)] + pub tmachine: Option, + #[serde(default)] + pub capabilities: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Tmachine, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Machine { + pub os: String, + pub version: String, + pub arch: Arch, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Arch { + Amd64, + Arm64, +} + +/// The tmachine environment and installer that provision this environment. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Tmachine { + pub environment: String, + pub installer: String, +} + +/// Selects environments by their fields. An omitted field matches any value, +/// so an empty selector matches every environment. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Selector { + pub id: Option, + pub backend: Option, + pub os: Option, + pub version: Option, + pub arch: Option, + pub runtime: Option, + pub installer: Option, +} + +impl Selector { + pub fn matches(&self, environment: &Environment) -> bool { + self.id.as_ref().is_none_or(|id| *id == environment.id) + && self + .backend + .is_none_or(|backend| backend == environment.backend) + && self + .os + .as_ref() + .is_none_or(|os| *os == environment.machine.os) + && self + .version + .as_ref() + .is_none_or(|version| *version == environment.machine.version) + && self + .arch + .is_none_or(|arch| arch == environment.machine.arch) + && self + .runtime + .as_ref() + .is_none_or(|runtime| *runtime == environment.runtime) + && self + .installer + .as_ref() + .is_none_or(|installer| *installer == environment.installer) + } +} + +impl fmt::Display for Selector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let fields: Vec = [ + ("id", self.id.clone()), + ("backend", self.backend.map(|backend| backend.to_string())), + ("os", self.os.clone()), + ("version", self.version.clone()), + ("arch", self.arch.map(|arch| arch.to_string())), + ("runtime", self.runtime.clone()), + ("installer", self.installer.clone()), + ] + .into_iter() + .filter_map(|(name, value)| value.map(|value| format!("{name}={value}"))) + .collect(); + if fields.is_empty() { + f.write_str("all environments") + } else { + f.write_str(&fields.join(", ")) + } + } +} + +impl fmt::Display for Backend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Tmachine => "tmachine", + }) + } +} + +impl fmt::Display for Arch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Amd64 => "amd64", + Self::Arm64 => "arm64", + }) + } +} + +impl Arch { + /// The architecture of the machine running this process. + pub const fn host() -> Option { + if cfg!(target_arch = "x86_64") { + Some(Self::Amd64) + } else if cfg!(target_arch = "aarch64") { + Some(Self::Arm64) + } else { + None + } + } +} + +impl Environment { + /// The ID implied by the environment's fields. + pub fn expected_id(&self) -> String { + format!( + "{}.{}{}-{}.{}.{}", + self.backend, + self.machine.os, + self.machine.version.replace('.', ""), + self.machine.arch, + self.runtime, + self.installer + ) + } + + fn validate(&self) -> Result<()> { + let expected = self.expected_id(); + ensure!( + self.id == expected, + "environment {:?} does not match its fields; expected ID {expected:?}", + self.id + ); + match self.backend { + Backend::Tmachine => ensure!( + self.tmachine.is_some(), + "environment {:?} uses the tmachine backend but has no `tmachine` section", + self.id + ), + } + Ok(()) + } + + /// Why the backend can't provision this environment on this host, if it + /// can't. tmachine boots guests of the host's architecture only. + pub fn unsupported_reason(&self, host: Option) -> Option { + match self.backend { + Backend::Tmachine if host != Some(self.machine.arch) => Some(format!( + "tmachine boots {} guests only on {} hosts", + self.machine.arch, self.machine.arch + )), + Backend::Tmachine => None, + } + } +} + +impl Catalog { + /// Load every `*.yaml` file in `dir`. + pub fn load_dir(dir: &Path) -> Result { + let mut paths: Vec<_> = std::fs::read_dir(dir) + .with_context(|| format!("read environments directory {}", dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>() + .with_context(|| format!("read environments directory {}", dir.display()))?; + paths.retain(|path| path.extension().is_some_and(|ext| ext == "yaml")); + paths.sort(); + + let mut catalog = Self::default(); + for path in paths { + let yaml = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + catalog + .add_yaml(&yaml) + .with_context(|| format!("load environments from {}", path.display()))?; + } + Ok(catalog) + } + + fn add_yaml(&mut self, yaml: &str) -> Result<()> { + let file: File = serde_yml::from_str(yaml)?; + for environment in file.environments { + environment.validate()?; + if self.environments.contains_key(&environment.id) { + bail!("environment {:?} is defined more than once", environment.id); + } + self.environments + .insert(environment.id.clone(), environment); + } + Ok(()) + } + + pub fn get(&self, id: &str) -> Option<&Environment> { + self.environments.get(id) + } + + pub fn iter(&self) -> impl Iterator { + self.environments.values() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + const UBUNTU_DOCKER: &str = " +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: \"24.04\", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + capabilities: { compute_driver: docker, gpu: false } +"; + + fn catalog(yaml: &str) -> Result { + let mut catalog = Catalog::default(); + catalog.add_yaml(yaml)?; + Ok(catalog) + } + + #[test] + fn loads_environment() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.machine.arch, Arch::Amd64); + assert_eq!( + env.tmachine, + Some(Tmachine { + environment: "ubuntu-docker-rootful".to_string(), + installer: "deb".to_string(), + }) + ); + assert_eq!( + env.capabilities.get("compute_driver"), + Some(&serde_yml::Value::from("docker")) + ); + } + + #[test] + fn rejects_id_that_does_not_match_fields() { + let yaml = UBUNTU_DOCKER.replace("docker-rootful.deb", "docker-rootless.deb"); + let err = catalog(&yaml).unwrap_err(); + assert!( + err.to_string().contains("does not match its fields"), + "{err}" + ); + } + + #[test] + fn rejects_tmachine_environment_without_tmachine_section() { + let yaml = UBUNTU_DOCKER.replace( + " tmachine: { environment: ubuntu-docker-rootful, installer: deb }\n", + "", + ); + let err = catalog(&yaml).unwrap_err(); + assert!(err.to_string().contains("no `tmachine` section"), "{err}"); + } + + #[test] + fn rejects_unknown_fields() { + let yaml = UBUNTU_DOCKER.replace("runtime:", "runtme:"); + assert!(catalog(&yaml).is_err()); + } + + #[test] + fn rejects_duplicate_ids() { + let mut catalog = catalog(UBUNTU_DOCKER).unwrap(); + let err = catalog.add_yaml(UBUNTU_DOCKER).unwrap_err(); + assert!(err.to_string().contains("more than once"), "{err}"); + } + + #[test] + fn reports_arch_mismatch_as_unsupported() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.unsupported_reason(Some(Arch::Amd64)), None); + assert!(env.unsupported_reason(Some(Arch::Arm64)).is_some()); + } + + #[test] + fn selectors_match_by_field() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + let select = |yaml: &str| serde_yml::from_str::(yaml).unwrap(); + assert!(select("{}").matches(env)); + assert!(select("{ runtime: docker-rootful, arch: amd64 }").matches(env)); + assert!(!select("{ runtime: docker-rootful, arch: arm64 }").matches(env)); + assert!(!select("{ installer: rpm }").matches(env)); + assert_eq!(select("{}").to_string(), "all environments"); + assert_eq!( + select("{ arch: amd64, runtime: docker-rootful }").to_string(), + "arch=amd64, runtime=docker-rootful" + ); + assert!(serde_yml::from_str::("{ runtme: x }").is_err()); + } + + #[test] + fn repository_environments_are_valid() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../environments"); + let catalog = Catalog::load_dir(&dir).unwrap(); + assert!( + catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .is_some() + ); + } +} diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs new file mode 100644 index 0000000000..dc24013e7d --- /dev/null +++ b/tests/runner/src/execute.rs @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Running a job: provision the environment, run each family's bundle in it, +//! and bring the results back. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Output; + +use anyhow::{Context, Result, bail, ensure}; +use tmachine::Session; +use tmachine::config::Config; + +use crate::bundle::{Bundle, PROFILE, READY_BINARY}; +use crate::jobs::{FamilyJob, Job}; +use crate::matrix::Exclusion; +use crate::report::{Outcome, Status, annotate_junit, junit_counts}; + +/// Where the installer describes the gateway in the guest. +const GATEWAY_SECTION: &str = "/etc/openshell-test/gateway.yaml"; +const GUEST_ROOT: &str = "/var/tmp/openshell-tests"; +/// How long the gateway has to become healthy after the environment is up. +// Matches the installers' own wait: an HA k3s gateway booted from a cached +// disk can take minutes to report healthy. +const READY_TIMEOUT_SECS: u64 = 300; + +pub struct Options<'a> { + pub tmachine: &'a Config, + pub bundles: &'a BTreeMap, + pub results: &'a Path, + pub keep: bool, +} + +fn count(family: &FamilyJob, kind: impl Fn(&Exclusion) -> bool) -> usize { + family + .suites + .iter() + .flat_map(|suite| &suite.excluded) + .filter(|test| kind(&test.reason)) + .count() +} + +/// Run one job. Every family gets an outcome, even when provisioning fails. +pub async fn run(job: &Job, options: &Options<'_>) -> Vec { + let env_id = &job.environment.id; + let outcome = |family: &FamilyJob, status| Outcome { + environment: env_id.clone(), + family: family.family.clone(), + status, + unsupported_tests: count(family, |reason| matches!(reason, Exclusion::Missing(_))), + waived_tests: count(family, |reason| matches!(reason, Exclusion::Waived(_))), + }; + + if let Some(reason) = &job.unsupported { + return job + .families + .iter() + .map(|family| outcome(family, Status::Unsupported(reason.clone()))) + .collect(); + } + if !job.families.iter().any(FamilyJob::has_tests) { + return job + .families + .iter() + .map(|family| outcome(family, Status::NothingToRun)) + .collect(); + } + + eprintln!("==> {env_id}: provisioning"); + let session = match provision(job, options.tmachine).await { + Ok(session) => session, + Err(error) => { + let message = format!("provision environment: {error:#}"); + eprintln!("==> {env_id}: {message}"); + return job + .families + .iter() + .map(|family| outcome(family, Status::Error(message.clone()))) + .collect(); + } + }; + + let mut outcomes = Vec::new(); + for family in &job.families { + if !family.has_tests() { + outcomes.push(outcome(family, Status::NothingToRun)); + continue; + } + let dir = options.results.join(env_id).join(&family.family); + eprintln!( + "==> {env_id}: running {} ({})", + family.family, + family.filterset() + ); + let status = match run_family(&session, job, family, options, &dir).await { + Ok(status) => status, + Err(error) => Status::Error(format!("{error:#}")), + }; + if status.is_failure() + && let Err(error) = session.collect_diagnostics(&dir.join("diagnostics")).await + { + eprintln!("==> {env_id}: collecting diagnostics failed: {error:#}"); + } + eprintln!("==> {env_id}: {} {status:?}", family.family); + outcomes.push(outcome(family, status)); + } + + if options.keep { + eprintln!( + "==> {env_id}: keeping the VM. Connect with `ssh -p 2222 tmachine@127.0.0.1` (password `tmachine`). Press Enter to tear it down." + ); + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + } + eprintln!("==> {env_id}: tearing down"); + session.down().await; + outcomes +} + +async fn provision(job: &Job, config: &Config) -> Result { + let params = job + .environment + .tmachine + .as_ref() + .context("environment has no tmachine section")?; + let environment = config + .environments + .iter() + .find(|candidate| candidate.name == params.environment) + .with_context(|| { + format!( + "tmachine environment {:?} is not defined", + params.environment + ) + })?; + let machine = config + .machines + .iter() + .find(|candidate| candidate.name == environment.machine) + .with_context(|| format!("tmachine machine {:?} is not defined", environment.machine))?; + let installer = config + .installers + .iter() + .find(|candidate| candidate.name == params.installer) + .with_context(|| format!("tmachine installer {:?} is not defined", params.installer))?; + Session::up(machine, environment, installer).await +} + +async fn run_family( + session: &Session, + job: &Job, + family: &FamilyJob, + options: &Options<'_>, + dir: &Path, +) -> Result { + if dir.exists() { + std::fs::remove_dir_all(dir).with_context(|| format!("clear {}", dir.display()))?; + } + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let bundle = options + .bundles + .get(&family.family) + .with_context(|| format!("no bundle was built for {}", family.family))?; + + // The installer knows how to reach the gateway; the environment + // definition knows what it provides. Together they are the target. + let gateway = checked( + "read the installer's gateway description", + session.exec(&["cat", GATEWAY_SECTION]).await?, + )?; + let capabilities = serde_yml::to_string(&BTreeMap::from([( + "capabilities", + &job.environment.capabilities, + )]))?; + let target = dir.join("target.yaml"); + std::fs::write(&target, format!("{gateway}{capabilities}"))?; + + let guest = format!("{GUEST_ROOT}/{}", family.family); + checked( + "prepare guest directory", + session + .exec(&[ + "sh", + "-c", + &format!("rm -rf {guest} && mkdir -p {guest}/extract"), + ]) + .await?, + )?; + session + .copy_in(&bundle.path, &format!("{guest}/bundle.tar")) + .await?; + session + .copy_in(&target, &format!("{guest}/target.yaml")) + .await?; + checked( + "unpack bundle", + session + .exec(&["tar", "-C", &guest, "-xf", &format!("{guest}/bundle.tar")]) + .await?, + )?; + + // A booted guest restarts its gateway, so wait for it once here rather + // than in every test. Failing here is an environment error, not a test + // failure. + let target_env = format!("OPENSHELL_TEST_TARGET={guest}/target.yaml"); + checked( + "wait for the gateway", + session + .exec(&[ + "env", + &target_env, + &format!("{guest}/{READY_BINARY}"), + &READY_TIMEOUT_SECS.to_string(), + ]) + .await?, + )?; + + let filterset = family.filterset(); + let output = session + .exec(&[ + "env", + &target_env, + "cargo-nextest", + "nextest", + "run", + "--archive-file", + &format!("{guest}/tests.tar.zst"), + "--workspace-remap", + &guest, + "--extract-to", + &format!("{guest}/extract"), + "--extract-overwrite", + "--config-file", + &format!("{guest}/nextest.toml"), + "--profile", + PROFILE, + "--no-capture", + "-E", + &filterset, + ]) + .await?; + let mut log = output.stdout.clone(); + log.extend_from_slice(&output.stderr); + std::fs::write(dir.join("nextest.log"), &log)?; + + let junit = dir.join("junit.xml"); + let found = checked( + "find JUnit report", + session + .exec(&["find", &guest, "-name", "junit.xml"]) + .await?, + )?; + let counts = match found.lines().next() { + Some(path) => { + session.copy_out(path, &junit).await?; + let xml = annotate_junit(&std::fs::read_to_string(&junit)?, &family.test_ids()); + std::fs::write(&junit, &xml)?; + junit_counts(&xml) + } + None => None, + }; + + Ok(match (output.status.success(), counts) { + (true, Some((tests, 0))) => Status::Passed { tests }, + (true, counts) => bail!( + "nextest succeeded but the JUnit report is missing or reports failures ({counts:?}); see {}", + dir.join("nextest.log").display() + ), + (false, Some((tests, failures))) if failures > 0 => Status::Failed { tests, failures }, + (false, _) => Status::Error(format!( + "nextest exited with {}; see {}", + output.status, + dir.join("nextest.log").display() + )), + }) +} + +fn checked(step: &str, output: Output) -> Result { + ensure!( + output.status.success(), + "{step}: exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + String::from_utf8(output.stdout).with_context(|| format!("{step}: output is not UTF-8")) +} diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs new file mode 100644 index 0000000000..1234f4b91b --- /dev/null +++ b/tests/runner/src/jobs.rs @@ -0,0 +1,425 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Expanding a plan into jobs. +//! +//! Jobs are grouped by environment so each environment is provisioned once +//! and runs every family and suite the plan selects for it. Tests whose +//! requirements the environment lacks, or that a waiver skips there, are +//! excluded by exact name and reported with the reason; libtest has no +//! runtime skip, so this is the only place they can be skipped honestly. + +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; + +use crate::catalog::{Catalog, Suite}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::matrix::{Exclusion, exclusion}; +use crate::plan::Plan; +use crate::waivers::Waivers; + +/// Everything the plan runs in one environment. +#[derive(Debug)] +pub struct Job { + pub environment: Environment, + /// Why the environment can't be provisioned on this host, if it can't. + pub unsupported: Option, + pub families: Vec, +} + +/// The suites of one family selected for an environment. +#[derive(Debug)] +pub struct FamilyJob { + pub family: String, + pub suites: Vec, +} + +#[derive(Debug)] +pub struct SuiteJob { + pub suite: Suite, + /// Names of the tests to run. + pub run: Vec, + /// Tests excluded in this environment, with the reason. + pub excluded: Vec, +} + +#[derive(Debug)] +pub struct ExcludedTest { + pub name: String, + pub reason: Exclusion, +} + +/// Filters applied on top of the plan, from the command line. +#[derive(Debug, Default)] +pub struct Narrowing { + pub environments: Vec, + pub suites: Vec, +} + +/// Validate the plan against the catalogs and expand it into jobs. +pub fn expand( + plan: &Plan, + catalog: &Catalog, + environments: &Environments, + waivers: &Waivers, + host: Option, + narrowing: &Narrowing, +) -> Result> { + // environment ID -> family -> suite names, in plan order where it matters. + let mut selected: BTreeMap>> = BTreeMap::new(); + + for entry in &plan.matrix { + let family = catalog.family(&entry.family)?; + let suites: Vec = match &entry.suites { + Some(suites) => { + for suite in suites { + family.suite(suite)?; + } + suites.clone() + } + None => family.suites.keys().cloned().collect(), + }; + for id in &entry.environments { + if environments.get(id).is_none() { + bail!( + "unknown environment {id:?}; known environments: {}", + environments + .iter() + .map(|env| env.id.as_str()) + .collect::>() + .join(", ") + ); + } + let family_suites = selected + .entry(id.clone()) + .or_default() + .entry(entry.family.clone()) + .or_default(); + for suite in &suites { + if !family_suites.contains(suite) { + family_suites.push(suite.clone()); + } + } + } + } + + for id in &narrowing.environments { + if !selected.contains_key(id) { + bail!("--env {id:?} is not in the plan"); + } + } + for suite in &narrowing.suites { + let in_plan = selected + .values() + .flat_map(BTreeMap::values) + .any(|suites| suites.contains(suite)); + if !in_plan { + bail!("--suite {suite:?} is not in the plan"); + } + } + + let mut jobs = Vec::new(); + for (id, families) in selected { + if !narrowing.environments.is_empty() && !narrowing.environments.contains(&id) { + continue; + } + let environment = environments.get(&id).expect("validated above").clone(); + let mut family_jobs = Vec::new(); + for (family_name, suites) in families { + let family = catalog.family(&family_name)?; + let suites: Vec = suites + .iter() + .filter(|suite| narrowing.suites.is_empty() || narrowing.suites.contains(suite)) + .map(|suite| { + suite_job( + family.suite(suite).expect("validated above"), + &environment, + waivers, + ) + }) + .collect(); + if !suites.is_empty() { + family_jobs.push(FamilyJob { + family: family_name, + suites, + }); + } + } + if family_jobs.is_empty() { + continue; + } + jobs.push(Job { + unsupported: environment.unsupported_reason(host), + environment, + families: family_jobs, + }); + } + Ok(jobs) +} + +fn suite_job(suite: &Suite, environment: &Environment, waivers: &Waivers) -> SuiteJob { + let mut run = Vec::new(); + let mut excluded = Vec::new(); + for test in &suite.tests { + match exclusion(test, environment, waivers) { + None => run.push(test.name.clone()), + Some(reason) => excluded.push(ExcludedTest { + name: test.name.clone(), + reason, + }), + } + } + SuiteJob { + suite: suite.clone(), + run, + excluded, + } +} + +impl FamilyJob { + /// Whether any test will run. + pub fn has_tests(&self) -> bool { + self.suites.iter().any(|suite| !suite.run.is_empty()) + } + + /// Stable test IDs keyed by nextest `JUnit` `(classname, name)`. + pub fn test_ids(&self) -> BTreeMap<(String, String), String> { + self.suites + .iter() + .flat_map(|job| { + let classname = format!("{}::{}", job.suite.package, job.suite.binary); + job.suite + .tests + .iter() + .map(move |test| ((classname.clone(), test.name.clone()), test.id.clone())) + }) + .collect() + } + + /// The nextest filterset selecting exactly the tests to run. + pub fn filterset(&self) -> String { + self.suites + .iter() + .filter(|suite| !suite.run.is_empty()) + .map(|suite| { + // Name the package too: other packages in the family can have test + // binaries with the same name. + let binary = format!( + "binary_id(={}::{})", + suite.suite.package, suite.suite.binary + ); + if suite.excluded.is_empty() { + binary + } else { + let tests = suite + .run + .iter() + .map(|id| format!("test(={id})")) + .collect::>() + .join(" | "); + format!("({binary} & ({tests}))") + } + }) + .collect::>() + .join(" | ") + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Test}; + + fn catalog() -> Catalog { + let suite = |name: &str, tests: &[(&str, &[&str])]| Suite { + suite: name.to_string(), + binary: name.replace('-', "_"), + package: format!("pkg-{name}"), + tests: tests + .iter() + .enumerate() + .map(|(index, (name, requires))| Test { + id: format!("t-{index:08}"), + name: (*name).to_string(), + description: "d".to_string(), + requires: requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [ + suite( + "sandbox-lifecycle", + &[("long_running", &[]), ("expose", &["service_exposure"])], + ), + suite("providers", &[("create_provider", &[])]), + ] + .into_iter() + .map(|suite| (suite.suite.clone(), suite)) + .collect(), + }; + Catalog { + families: [("conformance".to_string(), family)].into(), + } + } + + fn environments() -> Environments { + use std::fmt::Write as _; + + let mut yaml = String::from("environments:\n"); + for arch in ["amd64", "arm64"] { + let _ = write!( + yaml, + " - id: tmachine.ubuntu2404-{arch}.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: {arch} }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n capabilities: {{ compute_driver: docker }}\n" + ); + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + const AMD64: &str = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + const ARM64: &str = "tmachine.ubuntu2404-arm64.docker-rootful.deb"; + + fn expand_plan(yaml: &str, narrowing: &Narrowing) -> Result> { + expand( + &Plan::parse(yaml).unwrap(), + &catalog(), + &environments(), + &Waivers::default(), + Some(Arch::Amd64), + narrowing, + ) + } + + #[test] + fn excludes_tests_with_unmet_requirements() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + let suite = &jobs[0].families[0].suites[0]; + assert_eq!(suite.run, ["long_running"]); + assert_eq!(suite.excluded[0].name, "expose"); + assert_eq!( + suite.excluded[0].reason, + Exclusion::Missing(vec!["service_exposure".to_string()]) + ); + assert_eq!( + jobs[0].families[0].filterset(), + "(binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle) & (test(=long_running)))" + ); + } + + #[test] + fn omitted_suites_select_the_whole_family() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + let suites: Vec<_> = jobs[0].families[0] + .suites + .iter() + .map(|suite| suite.suite.suite.as_str()) + .collect(); + assert_eq!(suites, ["providers", "sandbox-lifecycle"]); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-providers::providers) | (binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle) & (test(=long_running)))" + ); + } + + #[test] + fn groups_entries_by_environment() { + let jobs = expand_plan( + &format!( + "matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}, {ARM64}]\n - type: conformance\n suites: [providers, sandbox-lifecycle]\n environments: [{AMD64}]\n" + ), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 2); + let amd64 = jobs.iter().find(|job| job.environment.id == AMD64).unwrap(); + assert_eq!(amd64.families.len(), 1); + assert_eq!(amd64.families[0].suites.len(), 2); + assert!(amd64.unsupported.is_none()); + let arm64 = jobs.iter().find(|job| job.environment.id == ARM64).unwrap(); + assert!(arm64.unsupported.is_some()); + } + + #[test] + fn narrows_to_environment_and_suite() { + let narrowing = Narrowing { + environments: vec![AMD64.to_string()], + suites: vec!["providers".to_string()], + }; + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}, {ARM64}]\n"), + &narrowing, + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].families[0].suites.len(), 1); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-providers::providers)" + ); + } + + #[test] + fn rejects_unknown_names() { + let unknown_env = expand_plan( + "matrix:\n - type: conformance\n environments: [nope]\n", + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_env.to_string().contains("unknown environment"), + "{unknown_env}" + ); + + let unknown_suite = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecyle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_suite + .to_string() + .contains("unknown conformance suite"), + "{unknown_suite}" + ); + + let unknown_family = expand_plan( + &format!("matrix:\n - type: features\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_family.to_string().contains("unknown test family"), + "{unknown_family}" + ); + + let narrowing = Narrowing { + environments: vec![ARM64.to_string()], + suites: vec![], + }; + let not_in_plan = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &narrowing, + ) + .unwrap_err(); + assert!( + not_in_plan.to_string().contains("not in the plan"), + "{not_in_plan}" + ); + } +} diff --git a/tests/runner/src/lib.rs b/tests/runner/src/lib.rs new file mode 100644 index 0000000000..e6f5ae0c8e --- /dev/null +++ b/tests/runner/src/lib.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runs `OpenShell` test plans: expands a plan into jobs, provisions each +//! environment through its backend, runs the selected suites inside it, and +//! reports the results. + +pub mod bundle; +pub mod catalog; +pub mod environments; +pub mod execute; +pub mod jobs; +pub mod matrix; +pub mod plan; +pub mod report; +pub mod waivers; diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs new file mode 100644 index 0000000000..66175b2b2f --- /dev/null +++ b/tests/runner/src/main.rs @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `openshell-test`: run `OpenShell` test plans. +//! +//! Run it through `nix run .#test-runner -- `, which provides the +//! guest cross-compilation toolchain, QEMU, Ansible, and the tmachine +//! configuration. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use openshell_test_runner::bundle::{self, Bundle}; +use openshell_test_runner::catalog::Catalog; +use openshell_test_runner::environments::{Arch, Catalog as Environments}; +use openshell_test_runner::execute::{self, Options}; +use openshell_test_runner::jobs::{self, Job, Narrowing}; +use openshell_test_runner::matrix::{self, Exclusion, Matrix}; +use openshell_test_runner::plan::Plan; +use openshell_test_runner::report; +use openshell_test_runner::waivers::Waivers; + +#[derive(Parser)] +#[command(name = "openshell-test", about = "Run OpenShell test plans")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a test plan. + Test { + /// The plan to run, e.g. tests/plans/cpu-tests.yaml. + #[arg(long)] + plan: PathBuf, + /// Print the resolved jobs without building or provisioning anything. + #[arg(long)] + dry_run: bool, + /// Keep each environment running after its tests until Enter is pressed. + #[arg(long)] + keep: bool, + /// Only run these environments from the plan. + #[arg(long = "env", value_name = "ID")] + environments: Vec, + /// Only run these suites from the plan. + #[arg(long = "suite", value_name = "NAME")] + suites: Vec, + /// Results directory [default: artifacts/test-results/]. + #[arg(long)] + results: Option, + /// Run waived tests too, e.g. to check whether a fix landed. + #[arg(long)] + ignore_waivers: bool, + }, + /// Print every test against every environment: where it runs, or why not. + Matrix { + /// Also exclude environments this machine can't provision. + #[arg(long)] + host: bool, + /// Ignore tests/waivers.yaml. + #[arg(long)] + ignore_waivers: bool, + /// Print JSON instead of text. + #[arg(long)] + json: bool, + }, + /// Print the test catalog as JSON. + Catalog, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + match run(Cli::parse()).await { + Ok(code) => code, + Err(error) => { + eprintln!("error: {error:#}"); + ExitCode::FAILURE + } + } +} + +async fn run(cli: Cli) -> Result { + let root = repo_root()?; + let catalog = Catalog::load(&root)?; + let environments_catalog = Environments::load_dir(&root.join("tests/environments"))?; + let load_waivers = |ignore: bool| -> Result { + if ignore { + return Ok(Waivers::default()); + } + Waivers::load( + &root.join("tests/waivers.yaml"), + &catalog, + &environments_catalog, + ) + }; + + match cli.command { + Command::Catalog => { + println!("{}", serde_json::to_string_pretty(&catalog)?); + Ok(ExitCode::SUCCESS) + } + Command::Matrix { + host, + ignore_waivers, + json, + } => { + let waivers = load_waivers(ignore_waivers)?; + let matrix = Matrix::build( + &catalog, + &environments_catalog, + &waivers, + host.then(Arch::host), + ); + if json { + println!("{}", serde_json::to_string_pretty(&matrix)?); + } else { + print!("{}", matrix.render()); + } + let problems = matrix::problems(&catalog, &environments_catalog); + for problem in &problems { + eprintln!("error: {problem}"); + } + Ok(if problems.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) + } + Command::Test { + plan, + dry_run, + keep, + environments, + suites, + results, + ignore_waivers, + } => { + let problems = matrix::problems(&catalog, &environments_catalog); + if !problems.is_empty() { + bail!( + "the test matrix is inconsistent; see `matrix`:\n {}", + problems.join("\n ") + ); + } + let waivers = load_waivers(ignore_waivers)?; + let plan_path = plan; + let plan = Plan::load(&plan_path)?; + let host = Arch::host(); + let narrowing = Narrowing { + environments, + suites, + }; + let jobs = jobs::expand( + &plan, + &catalog, + &environments_catalog, + &waivers, + host, + &narrowing, + )?; + print_jobs(&jobs); + if dry_run { + return Ok(ExitCode::SUCCESS); + } + + // Absolute, because `load_tmachine` changes the working directory. + let results = match results { + Some(results) => std::path::absolute(&results) + .with_context(|| format!("resolve {}", results.display()))?, + None => root.join("artifacts/test-results").join( + plan_path + .file_stem() + .context("plan path has no file name")?, + ), + }; + std::fs::create_dir_all(&results) + .with_context(|| format!("create {}", results.display()))?; + std::fs::write( + results.join("suites.json"), + serde_json::to_string_pretty(&catalog)?, + )?; + + let bundles = build_bundles(&root, &jobs, &catalog, host)?; + let tmachine = load_tmachine(&root)?; + let options = Options { + tmachine: &tmachine, + bundles: &bundles, + results: &results, + keep, + }; + let mut outcomes = Vec::new(); + for job in &jobs { + outcomes.extend(execute::run(job, &options).await); + } + + eprintln!("{}", report::summary(&outcomes)); + print_waived(&jobs); + eprintln!("Results: {}", results.display()); + Ok( + if outcomes.iter().any(|outcome| outcome.status.is_failure()) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }, + ) + } + } +} + +fn print_jobs(jobs: &[Job]) { + for job in jobs { + eprintln!("{}", job.environment.id); + if let Some(reason) = &job.unsupported { + eprintln!(" unsupported on this host: {reason}"); + } + for family in &job.families { + eprintln!(" {}: -E '{}'", family.family, family.filterset()); + for suite in &family.suites { + eprintln!(" {}: {}", suite.suite.suite, suite.run.join(", ")); + for test in &suite.excluded { + eprintln!( + " {}: {} excluded ({})", + suite.suite.suite, test.name, test.reason + ); + } + } + } + } +} + +/// List the waivers that skipped tests, so they stay in view. +fn print_waived(jobs: &[Job]) { + let waived: Vec = jobs + .iter() + .filter(|job| job.unsupported.is_none()) + .flat_map(|job| { + job.families.iter().flat_map(move |family| { + family.suites.iter().flat_map(move |suite| { + suite + .excluded + .iter() + .filter_map(move |test| match &test.reason { + Exclusion::Waived(waiver) => Some(format!( + " {} / {}: {} ({})", + job.environment.id, test.name, waiver.reason, waiver.issue + )), + _ => None, + }) + }) + }) + }) + .collect(); + if !waived.is_empty() { + eprintln!("Waived (skipped; run with --ignore-waivers to check fixes):"); + for line in waived { + eprintln!("{line}"); + } + eprintln!(); + } +} + +/// Build one bundle per family, covering every suite that will run. +fn build_bundles( + root: &Path, + jobs: &[Job], + catalog: &Catalog, + host: Option, +) -> Result> { + let mut suites: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for job in jobs.iter().filter(|job| job.unsupported.is_none()) { + for family in &job.families { + for suite in family.suites.iter().filter(|suite| !suite.run.is_empty()) { + suites + .entry(family.family.as_str()) + .or_default() + .insert(suite.suite.suite.as_str()); + } + } + } + + let mut bundles = BTreeMap::new(); + if suites.is_empty() { + return Ok(bundles); + } + let target = bundle::guest_target(host.context("the host architecture is not supported")?); + eprintln!("==> building {} for {target}", bundle::READY_BINARY); + let ready = bundle::build_ready(root, target)?; + for (family_name, suite_names) in suites { + let family = catalog.family(family_name)?; + let binaries = suite_names + .iter() + .map(|name| family.suite(name).map(|suite| suite.binary.clone())) + .collect::>()?; + eprintln!("==> building {family_name} bundle for {target}"); + let bundle = bundle::build( + family, + &family.packages(suite_names.iter().copied()), + &binaries, + target, + &ready, + )?; + bundles.insert(family_name.to_string(), bundle); + } + Ok(bundles) +} + +/// Load the tmachine configuration and prepare the process to drive it. +/// +/// tmachine configs name playbooks and inputs relative to `tests/`, and its +/// Ansible runs read `ANSIBLE_CONFIG`. +fn load_tmachine(root: &Path) -> Result { + let path = std::env::var_os("TMACHINE_CONFIG").context( + "TMACHINE_CONFIG is not set; run through `nix run .#test-runner`, which provides it", + )?; + let config = tmachine::config::Config::load(Path::new(&path))?; + let tests = root.join("tests"); + std::env::set_current_dir(&tests).with_context(|| format!("enter {}", tests.display()))?; + if std::env::var_os("ANSIBLE_CONFIG").is_none() { + // SAFETY: single-threaded at this point; no other thread reads the + // environment concurrently. + unsafe { std::env::set_var("ANSIBLE_CONFIG", tests.join("ansible/ansible.cfg")) }; + } + Ok(config) +} + +fn repo_root() -> Result { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("run git rev-parse")?; + anyhow::ensure!( + output.status.success(), + "not inside the OpenShell repository" + ); + Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim())) +} diff --git a/tests/runner/src/matrix.rs b/tests/runner/src/matrix.rs new file mode 100644 index 0000000000..1c6b862d73 --- /dev/null +++ b/tests/runner/src/matrix.rs @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The expected matrix: every test against every defined environment. +//! +//! A test is expected in every environment that provides the capabilities +//! it requires. Nobody lists combinations: tests say what they need, +//! environments say what they provide. Waivers then skip known bugs, and +//! the host check excludes environments this machine can't boot. Plans only +//! select from the result; they never decide what is expected. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::{self, Write as _}; + +use serde::Serialize; + +use crate::catalog::{Catalog, Test}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::waivers::{Waiver, Waivers}; + +/// Why a test does not run in an environment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Exclusion { + /// The environment lacks capabilities the test requires. + Missing(Vec), + /// A waiver skips the test there. + Waived(Waiver), + /// This machine can't provision the environment. + Host(String), +} + +impl fmt::Display for Exclusion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing(capabilities) => write!(f, "needs {}", capabilities.join(", ")), + Self::Waived(waiver) => write!(f, "waived: {} ({})", waiver.reason, waiver.issue), + Self::Host(reason) => write!(f, "host: {reason}"), + } + } +} + +/// Capabilities the test requires that the environment doesn't provide. +/// Requirements name boolean capabilities. +pub fn missing_capabilities(test: &Test, environment: &Environment) -> Vec { + test.requires + .iter() + .filter(|capability| { + environment.capabilities.get(capability.as_str()) != Some(&serde_yml::Value::Bool(true)) + }) + .cloned() + .collect() +} + +/// Why the test doesn't run in the environment, ignoring the host: a missing +/// capability first, then a waiver. +pub fn exclusion(test: &Test, environment: &Environment, waivers: &Waivers) -> Option { + let missing = missing_capabilities(test, environment); + if !missing.is_empty() { + return Some(Exclusion::Missing(missing)); + } + waivers + .find(&test.id, environment) + .map(|waiver| Exclusion::Waived(waiver.clone())) +} + +/// Problems that make the matrix inconsistent, judged by capabilities alone: +/// a test no defined environment can run, and a capability tests require +/// that no environment declares. Waivers and the host don't count; a test +/// only needs to be runnable somewhere in principle. +pub fn problems(catalog: &Catalog, environments: &Environments) -> Vec { + let declared: BTreeSet<&str> = environments + .iter() + .flat_map(|env| { + env.capabilities + .iter() + .filter(|(_, value)| **value == serde_yml::Value::Bool(true)) + .map(|(name, _)| name.as_str()) + }) + .collect(); + + let mut undeclared: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut problems = Vec::new(); + for (family, suite, test) in catalog.tests() { + let location = format!( + "{}/{}/{} ({})", + family.name, suite.suite, test.name, test.id + ); + for capability in &test.requires { + if !declared.contains(capability.as_str()) { + undeclared + .entry(capability) + .or_default() + .push(location.clone()); + } + } + if !environments + .iter() + .any(|env| missing_capabilities(test, env).is_empty()) + { + problems.push(format!( + "{location} runs in no defined environment; requires {}", + test.requires.join(", ") + )); + } + } + for (capability, tests) in undeclared { + problems.push(format!( + "capability {capability} is required by {} but no environment declares it", + tests.join(", ") + )); + } + problems +} + +/// Every test against every environment. +#[derive(Debug, Serialize)] +pub struct Matrix { + pub environments: Vec, + pub tests: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Row { + pub family: String, + pub suite: String, + pub name: String, + pub id: String, + /// Keyed by environment ID: `run`, or why the test doesn't run there. + pub cells: BTreeMap, +} + +impl Matrix { + /// Build the matrix. With `host`, also exclude environments that machine + /// can't provision. + pub fn build( + catalog: &Catalog, + environments: &Environments, + waivers: &Waivers, + host: Option>, + ) -> Self { + let tests = catalog + .tests() + .map(|(family, suite, test)| Row { + family: family.name.clone(), + suite: suite.suite.clone(), + name: test.name.clone(), + id: test.id.clone(), + cells: environments + .iter() + .map(|env| { + let exclusion = exclusion(test, env, waivers).or_else(|| { + host.and_then(|host| env.unsupported_reason(host).map(Exclusion::Host)) + }); + let cell = exclusion.map_or_else(|| "run".to_string(), |e| e.to_string()); + (env.id.clone(), cell) + }) + .collect(), + }) + .collect(); + Self { + environments: environments.iter().map(|env| env.id.clone()).collect(), + tests, + } + } + + /// The matrix as text: each test, then where it runs or why not. + pub fn render(&self) -> String { + let width = self.environments.iter().map(String::len).max().unwrap_or(0); + let mut out = String::new(); + let mut runs = 0; + let mut excluded = 0; + for row in &self.tests { + let _ = writeln!( + out, + "{}/{}/{} ({})", + row.family, row.suite, row.name, row.id + ); + for (env, cell) in &row.cells { + let _ = writeln!(out, " {env:width$} {cell}"); + if cell == "run" { + runs += 1; + } else { + excluded += 1; + } + } + } + let _ = writeln!( + out, + "\n{} tests x {} environments: {runs} run, {excluded} excluded", + self.tests.len(), + self.environments.len() + ); + out + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Suite}; + + fn environments(capabilities: &str) -> Environments { + let yaml = format!( + "environments:\n - id: tmachine.ubuntu2404-amd64.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: amd64 }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n capabilities: {capabilities}\n" + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + fn catalog(tests: &[(&str, &[&str])]) -> Catalog { + let suite = Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: tests + .iter() + .enumerate() + .map(|(index, (name, requires))| Test { + id: format!("t-{index:08}"), + name: (*name).to_string(), + description: "d".to_string(), + requires: requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [("s".to_string(), suite)].into(), + }; + Catalog { + families: [("conformance".to_string(), family)].into(), + } + } + + #[test] + fn reports_tests_no_environment_can_run() { + let catalog = catalog(&[("plain", &[]), ("exposed", &["service_exposure"])]); + let found = problems(&catalog, &environments("{ service_exposure: false }")); + assert_eq!(found.len(), 2, "{found:?}"); + assert!( + found[0].contains("conformance/s/exposed (t-00000001) runs in no defined environment") + ); + assert!(found[1].contains("capability service_exposure is required by")); + + assert!(problems(&catalog, &environments("{ service_exposure: true }")).is_empty()); + } + + #[test] + fn cells_give_the_reason_a_test_does_not_run() { + let catalog = catalog(&[("plain", &[]), ("exposed", &["service_exposure"])]); + let environments = environments("{ compute_driver: docker }"); + let env = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + + let matrix = Matrix::build(&catalog, &environments, &Waivers::default(), None); + assert_eq!(matrix.tests[0].cells[env], "run"); + assert_eq!(matrix.tests[1].cells[env], "needs service_exposure"); + + let on_arm = Matrix::build( + &catalog, + &environments, + &Waivers::default(), + Some(Some(Arch::Arm64)), + ); + assert!( + on_arm.tests[0].cells[env].starts_with("host: "), + "{:?}", + on_arm.tests[0].cells + ); + // A missing capability is reported before the host. + assert_eq!(on_arm.tests[1].cells[env], "needs service_exposure"); + + let text = matrix.render(); + assert!(text.contains("conformance/s/plain (t-00000000)"), "{text}"); + assert!( + text.contains("2 tests x 1 environments: 1 run, 1 excluded"), + "{text}" + ); + } + + #[test] + fn repository_matrix_is_consistent() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let catalog = Catalog::load(&root).unwrap(); + let environments = Environments::load_dir(&root.join("tests/environments")).unwrap(); + let problems = problems(&catalog, &environments); + assert!(problems.is_empty(), "{problems:#?}"); + } +} diff --git a/tests/runner/src/plan.rs b/tests/runner/src/plan.rs new file mode 100644 index 0000000000..ba172cfe70 --- /dev/null +++ b/tests/runner/src/plan.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test plans: `tests/plans/*.yaml`. +//! +//! ```yaml +//! matrix: +//! - type: conformance +//! suites: [sandbox-lifecycle] # omit for every suite in the family +//! environments: [tmachine.ubuntu2404-amd64.docker-rootful.deb] +//! ``` + +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub matrix: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + /// The test family, e.g. `conformance`. + #[serde(rename = "type")] + pub family: String, + /// Suites to run. `None` selects every suite in the family. + #[serde(default)] + pub suites: Option>, + pub environments: Vec, +} + +impl Plan { + pub fn load(path: &Path) -> Result { + let yaml = std::fs::read_to_string(path) + .with_context(|| format!("read plan {}", path.display()))?; + Self::parse(&yaml).with_context(|| format!("load plan {}", path.display())) + } + + pub fn parse(yaml: &str) -> Result { + let plan: Self = serde_yml::from_str(yaml)?; + ensure!(!plan.matrix.is_empty(), "plan has an empty matrix"); + for (index, entry) in plan.matrix.iter().enumerate() { + ensure!( + !entry.environments.is_empty(), + "matrix entry {index} ({}) names no environments", + entry.family + ); + ensure!( + entry + .suites + .as_ref() + .is_none_or(|suites| !suites.is_empty()), + "matrix entry {index} ({}) has an empty suite list; omit `suites` to select every suite", + entry.family + ); + } + Ok(plan) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plan() { + let plan = Plan::parse( + "matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [e1, e2]\n - type: conformance\n environments: [e3]\n", + ) + .unwrap(); + assert_eq!(plan.matrix.len(), 2); + assert_eq!(plan.matrix[0].family, "conformance"); + assert_eq!( + plan.matrix[0].suites.as_deref(), + Some(&["sandbox-lifecycle".to_string()][..]) + ); + assert_eq!(plan.matrix[1].suites, None); + } + + #[test] + fn rejects_unknown_fields() { + let err = + Plan::parse("matrix:\n - type: conformance\n suite: [x]\n environments: [e]\n") + .unwrap_err(); + assert!(format!("{err:#}").contains("suite"), "{err:#}"); + } + + #[test] + fn rejects_entry_without_environments() { + let err = + Plan::parse("matrix:\n - type: conformance\n environments: []\n").unwrap_err(); + assert!(err.to_string().contains("names no environments"), "{err}"); + } + + #[test] + fn rejects_empty_suite_list() { + let err = + Plan::parse("matrix:\n - type: conformance\n suites: []\n environments: [e]\n") + .unwrap_err(); + assert!(err.to_string().contains("empty suite list"), "{err}"); + } +} diff --git a/tests/runner/src/report.rs b/tests/runner/src/report.rs new file mode 100644 index 0000000000..14821bad08 --- /dev/null +++ b/tests/runner/src/report.rs @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Job outcomes and the run summary. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +/// The outcome of one family in one environment. +#[derive(Debug)] +pub struct Outcome { + pub environment: String, + pub family: String, + pub status: Status, + /// Tests excluded because the environment lacks a capability. + pub unsupported_tests: usize, + /// Tests a waiver skips in this environment. + pub waived_tests: usize, +} + +#[derive(Debug)] +pub enum Status { + Passed { + tests: u32, + }, + Failed { + tests: u32, + failures: u32, + }, + /// The environment can't be provisioned on this host. + Unsupported(String), + /// Every selected test needs a capability the environment lacks. + NothingToRun, + /// The runner could not complete the job. + Error(String), +} + +impl Status { + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Failed { .. } | Self::Error(_)) + } +} + +/// Test and failure counts from a `JUnit` report's root element. +pub fn junit_counts(xml: &str) -> Option<(u32, u32)> { + let start = xml.find("')?; + let root = &xml[start..end]; + let attr = |name: &str| -> Option { + let key = format!(" {name}=\""); + let value_start = root.find(&key)? + key.len(); + let value_end = value_start + root[value_start..].find('"')?; + root[value_start..value_end].parse().ok() + }; + let tests = attr("tests")?; + let failures = attr("failures").unwrap_or(0) + attr("errors").unwrap_or(0); + Some((tests, failures)) +} + +/// The `JUnit` property that carries a test's stable ID. +pub const TEST_ID_PROPERTY: &str = "test_id"; + +/// Add each test's stable ID to its `` as a property. +/// +/// `ids` maps a nextest `(classname, name)` pair (`::`, test +/// name) to the test's ID. Test cases not in `ids` are left unchanged. The +/// property is the test case's first child, where pytest's +/// `record_property` puts it. +pub fn annotate_junit(xml: &str, ids: &BTreeMap<(String, String), String>) -> String { + let mut out = String::with_capacity(xml.len()); + let mut rest = xml; + while let Some(start) = rest.find("') else { + break; + }; + let end = start + len; + let tag = &rest[start..end]; + out.push_str(&rest[..start]); + let key = attr(tag, "classname").zip(attr(tag, "name")); + match key.and_then(|(classname, name)| ids.get(&(classname.to_string(), name.to_string()))) + { + Some(id) => { + let properties = format!( + "" + ); + match tag.strip_suffix('/') { + Some(open) => { + let _ = write!(out, "{open}>{properties}"); + } + None => { + let _ = write!(out, "{tag}>{properties}"); + } + } + } + None => { + out.push_str(tag); + out.push('>'); + } + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + out +} + +/// An attribute value from a start tag. nextest escapes `"` in values. +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let start = tag.find(&key)? + key.len(); + let end = start + tag[start..].find('"')?; + Some(&tag[start..end]) +} + +/// A human-readable summary of every outcome. +pub fn summary(outcomes: &[Outcome]) -> String { + let mut out = String::from("\nSummary\n"); + for outcome in outcomes { + let status = match &outcome.status { + Status::Passed { tests } => format!("PASS {tests} passed"), + Status::Failed { tests, failures } => { + format!("FAIL {failures} of {tests} failed") + } + Status::Unsupported(reason) => format!("UNSUPPORTED {reason}"), + Status::NothingToRun => { + "UNSUPPORTED every selected test needs a missing capability".to_string() + } + Status::Error(error) => format!("ERROR {error}"), + }; + let mut excluded = Vec::new(); + if outcome.unsupported_tests > 0 { + excluded.push(format!("{} tests unsupported", outcome.unsupported_tests)); + } + if outcome.waived_tests > 0 { + excluded.push(format!("{} waived", outcome.waived_tests)); + } + let unsupported = if excluded.is_empty() { + String::new() + } else { + format!(" ({})", excluded.join(", ")) + }; + let _ = writeln!( + out, + " {} / {}: {status}{unsupported}", + outcome.environment, outcome.family + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_junit_counts() { + let xml = r#" + +"#; + assert_eq!(junit_counts(xml), Some((3, 2))); + } + + #[test] + fn annotates_test_cases_with_ids() { + let xml = r#" + + + + "#; + let ids = BTreeMap::from([ + ( + ("p::b".to_string(), "passed".to_string()), + "t-00000001".to_string(), + ), + ( + ("p::b".to_string(), "failed".to_string()), + "t-00000002".to_string(), + ), + ]); + let out = annotate_junit(xml, &ids); + assert!(out.contains( + r#""# + ), "{out}"); + assert!(out.contains( + r#""# + ), "{out}"); + assert!( + out.contains(r#""#), + "{out}" + ); + assert!(out.ends_with(""), "{out}"); + } + + #[test] + fn missing_root_has_no_counts() { + assert_eq!(junit_counts(""), None); + } + + #[test] + fn summarizes_outcomes() { + let text = summary(&[ + Outcome { + environment: "env-a".to_string(), + family: "conformance".to_string(), + status: Status::Passed { tests: 2 }, + unsupported_tests: 1, + waived_tests: 1, + }, + Outcome { + environment: "env-b".to_string(), + family: "conformance".to_string(), + status: Status::Unsupported("arch".to_string()), + unsupported_tests: 0, + waived_tests: 0, + }, + ]); + assert!( + text.contains( + "env-a / conformance: PASS 2 passed (1 tests unsupported, 1 waived)" + ), + "{text}" + ); + assert!( + text.contains("env-b / conformance: UNSUPPORTED arch"), + "{text}" + ); + } +} diff --git a/tests/runner/src/waivers.rs b/tests/runner/src/waivers.rs new file mode 100644 index 0000000000..04c1ba8493 --- /dev/null +++ b/tests/runner/src/waivers.rs @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Waivers: `tests/waivers.yaml`. +//! +//! A waiver excludes a test from the environments it selects because of a +//! known bug, tracked by an issue. It is the only explicit exclusion: a +//! feature an environment doesn't have is a missing capability, a machine +//! that can't boot an environment is the host check, and running less on a +//! given occasion is plan selection. Waived tests are skipped in every plan. + +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; + +use crate::catalog::Catalog; +use crate::environments::{Catalog as Environments, Environment, Selector}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + #[serde(default)] + waivers: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Waiver { + /// The test's stable ID. + pub test: String, + /// The environments the test is skipped in. Omit for all. + #[serde(default)] + pub environments: Selector, + /// The issue tracking the bug. + pub issue: String, + pub reason: String, +} + +#[derive(Debug, Default)] +pub struct Waivers { + waivers: Vec, +} + +impl Waivers { + /// Load and validate waivers. A missing file means no waivers. + pub fn load(path: &Path, catalog: &Catalog, environments: &Environments) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let yaml = + std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + Self::parse(&yaml, catalog, environments) + .with_context(|| format!("load waivers from {}", path.display())) + } + + fn parse(yaml: &str, catalog: &Catalog, environments: &Environments) -> Result { + let file: File = serde_yml::from_str(yaml)?; + let ids: BTreeSet<&str> = catalog + .tests() + .map(|(_, _, test)| test.id.as_str()) + .collect(); + for (index, waiver) in file.waivers.iter().enumerate() { + let at = format!("waiver for {}", waiver.test); + ensure!( + ids.contains(waiver.test.as_str()), + "{at}: no test has this ID; remove the waiver if the test is gone" + ); + ensure!( + environments + .iter() + .any(|env| waiver.environments.matches(env)), + "{at}: no environment matches {}", + waiver.environments + ); + ensure!( + waiver.issue.starts_with("https://"), + "{at}: `issue` must link the issue tracking the bug" + ); + ensure!(!waiver.reason.trim().is_empty(), "{at}: `reason` is empty"); + if file.waivers[..index] + .iter() + .any(|other| other.test == waiver.test && other.environments == waiver.environments) + { + bail!("{at}: duplicate waiver for {}", waiver.environments); + } + } + Ok(Self { + waivers: file.waivers, + }) + } + + /// The waiver that skips this test in this environment, if any. + pub fn find(&self, test_id: &str, environment: &Environment) -> Option<&Waiver> { + self.waivers + .iter() + .find(|waiver| waiver.test == test_id && waiver.environments.matches(environment)) + } + + pub fn iter(&self) -> impl Iterator { + self.waivers.iter() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn parse(yaml: &str) -> Result { + let catalog = Catalog::load(&root()).unwrap(); + let environments = Environments::load_dir(&root().join("tests/environments")).unwrap(); + Waivers::parse(yaml, &catalog, &environments) + } + + // long_running_exec_delete + const TEST: &str = "t-3tfdx240"; + + #[test] + fn finds_waivers_by_test_and_environment() { + let waivers = parse(&format!( + "waivers:\n - test: {TEST}\n environments: {{ arch: arm64 }}\n issue: https://github.com/NVIDIA/OpenShell/issues/1\n reason: broken on arm64\n" + )) + .unwrap(); + let environments = Environments::load_dir(&root().join("tests/environments")).unwrap(); + let amd64 = environments + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + let arm64 = environments + .get("tmachine.ubuntu2404-arm64.docker-rootful.deb") + .unwrap(); + assert!(waivers.find(TEST, amd64).is_none()); + assert_eq!(waivers.find(TEST, arm64).unwrap().reason, "broken on arm64"); + assert!(waivers.find("t-00000000", arm64).is_none()); + } + + #[test] + fn rejects_invalid_waivers() { + let cases = [ + ( + "waivers:\n - test: t-00000000\n issue: https://x\n reason: r\n".to_string(), + "no test has this ID", + ), + ( + format!( + "waivers:\n - test: {TEST}\n environments: {{ runtime: nope }}\n issue: https://x\n reason: r\n" + ), + "no environment matches", + ), + ( + format!("waivers:\n - test: {TEST}\n issue: '#123'\n reason: r\n"), + "must link the issue", + ), + ( + format!("waivers:\n - test: {TEST}\n issue: https://x\n reason: ' '\n"), + "`reason` is empty", + ), + ( + format!( + "waivers:\n - test: {TEST}\n issue: https://x\n reason: r\n - test: {TEST}\n issue: https://y\n reason: r\n" + ), + "duplicate waiver", + ), + ]; + for (yaml, expected) in cases { + let err = format!("{:#}", parse(&yaml).unwrap_err()); + assert!(err.contains(expected), "{expected}: {err}"); + } + assert!(parse("waivers:\n - test: x\n issu: y\n").is_err()); + } + + #[test] + fn repository_waivers_are_valid() { + let catalog = Catalog::load(&root()).unwrap(); + let environments = Environments::load_dir(&root().join("tests/environments")).unwrap(); + Waivers::load(&root().join("tests/waivers.yaml"), &catalog, &environments).unwrap(); + } +} diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 89d8c0d392..f3b33f0910 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2,436 +2,2938 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown 0.17.0", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-conformance" +version = "0.0.0" +dependencies = [ + "rand 0.9.4", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "openshell-conformance-file-transfer" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "tempfile", +] + +[[package]] +name = "openshell-conformance-policy-advisor" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "serde_json", + "tokio", +] + +[[package]] +name = "openshell-conformance-sandbox-lifecycle" +version = "0.0.0" +dependencies = [ + "openshell-testkit", +] + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror 2.0.20", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.20", +] + +[[package]] +name = "openshell-sdk" +version = "0.0.0" +dependencies = [ + "async-stream", + "async-trait", + "futures", + "hyper", + "hyper-util", + "miette", + "oauth2", + "openshell-core", + "reqwest", + "rustls", + "serde", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "openshell-test-conformance-cli" +version = "0.0.0" +dependencies = [ + "openshell-conformance", + "tokio", +] + +[[package]] +name = "openshell-testkit" +version = "0.0.0" +dependencies = [ + "futures", + "noyalib", + "openshell-sdk", + "openshell-testkit-macros", + "rand 0.9.4", + "serde", + "serde_json", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-testkit-macros" +version = "0.0.0" +dependencies = [ + "openshell-testkit-spec", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "errno" -version = "0.3.14" +name = "uuid" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "libc", - "windows-sys", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "getrandom" -version = "0.3.4" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", + "try-lock", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "libc" -version = "0.2.189" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "wasm-bindgen" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ - "scopeguard", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "wasm-bindgen-futures" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "mio" -version = "1.2.3" +name = "wasm-bindgen-macro" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ - "libc", - "wasi", - "windows-sys", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "wasm-bindgen-macro-support" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] [[package]] -name = "openshell-conformance" -version = "0.0.0" +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ - "rand", - "serde", - "serde_json", - "tempfile", - "tokio", + "unicode-ident", ] [[package]] -name = "openshell-test-conformance-cli" -version = "0.0.0" +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "openshell-conformance", - "tokio", + "leb128fmt", + "wasmparser", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "lock_api", - "parking_lot_core", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "web-sys" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "zerocopy", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "quote" -version = "1.0.47" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "rand" -version = "0.9.5" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "rand_chacha", - "rand_core", + "windows-link", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "ppv-lite86", - "rand_core", + "windows-link", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "getrandom", + "windows-targets", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "bitflags", + "windows-link", ] [[package]] -name = "rustix" -version = "1.1.5" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "serde" -version = "1.0.229" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "serde_core" -version = "1.0.229" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "serde_derive" -version = "1.0.229" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "serde_json" -version = "1.0.151" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "errno", - "libc", + "wit-bindgen-rust-macro", ] [[package]] -name = "smallvec" -version = "1.16.1" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "socket2" -version = "0.6.5" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "libc", - "windows-sys", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "syn" -version = "2.0.119" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "syn" -version = "3.0.5" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ + "anyhow", + "prettyplease", "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "tokio" -version = "1.53.1" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "tokio-macros" -version = "2.7.2" +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "zerocopy" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "zerocopy-derive" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ - "wit-bindgen", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "zerofrom" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ - "windows-link", + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "zeroize" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zerocopy" -version = "0.8.57" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ - "zerocopy-derive", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] -name = "zerocopy-derive" -version = "0.8.57" +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index 1c6b001096..aabae4b41a 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -3,4 +3,4 @@ [workspace] resolver = "2" -members = ["cli"] +members = ["cli", "file-transfer", "policy-advisor", "sandbox-lifecycle"] diff --git a/tests/suites/conformance/file-transfer/Cargo.toml b/tests/suites/conformance/file-transfer/Cargo.toml new file mode 100644 index 0000000000..a1dbf90867 --- /dev/null +++ b/tests/suites/conformance/file-transfer/Cargo.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance-file-transfer" +description = "Conformance suite: file-transfer" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +openshell-testkit = { path = "../../../testkit/testkit" } +tempfile = "3" diff --git a/tests/suites/conformance/file-transfer/tests/file_transfer.rs b/tests/suites/conformance/file-transfer/tests/file_transfer.rs new file mode 100644 index 0000000000..cecd960de1 --- /dev/null +++ b/tests/suites/conformance/file-transfer/tests/file_transfer.rs @@ -0,0 +1,418 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Conformance suite: file-transfer. +//! +//! Upload and download exist only in the CLI, which moves files over the +//! sandbox's SSH session, so these tests act through `cx.cli`. They depend on +//! the supervisor, the sandbox image, and the driver's filesystem, which is +//! why they run in every environment. + +use std::fs; +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; +use testkit::{CliOutput, Context, Error, Sandbox}; + +const LARGE_FILE_SIZE: usize = 512 * 1024; + +/// Files and directories round-trip through upload and download unchanged, +/// uploads into existing directories land inside them, and downloads of +/// single files and directories reproduce them locally. +#[testkit::test(id = "t-ifeaivyq")] +async fn upload_download_round_trip(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fr").await?; + ft.upload_into_existing_directories().await?; + ft.round_trip().await?; + ft.download_file().await?; + ft.download_directory().await?; + ft.sandbox.delete().await +} + +/// Uploads from a Git work tree skip ignored files, a single file uploads +/// alone, and an upload whose selection is entirely ignored is rejected +/// unless `--no-git-ignore` is given. +#[testkit::test(id = "t-0no9ex2m")] +async fn upload_respects_gitignore(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fg").await?; + ft.gitignore_filtering().await?; + ft.single_file_from_git_repo().await?; + ft.ignored_directory_requires_override().await?; + ft.sandbox.delete().await +} + +/// Downloads refuse sandbox paths that resolve outside the workspace, and +/// file names that look like command-line options download as plain files. +#[testkit::test(id = "t-9gr3oxym")] +async fn download_stays_inside_workspace(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fs").await?; + ft.reject_workspace_escape().await?; + ft.download_dash_leading_name().await?; + ft.sandbox.delete().await +} + +/// A sandbox, a directory in its workspace, and a local scratch directory. +struct Transfer<'a> { + cx: &'a Context, + sandbox: Sandbox, + remote_root: String, + local: TempDir, +} + +impl<'a> Transfer<'a> { + async fn new(cx: &'a Context, suffix: &str) -> testkit::Result { + let sandbox = cx + .sandbox(suffix) + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", cx.run_id()); + let local = tempfile::tempdir() + .map_err(|error| Error::msg(format!("create a local scratch directory: {error}")))?; + let ft = Self { + cx, + sandbox, + remote_root, + local, + }; + ft.sh(&format!("mkdir -p '{}'", ft.remote_root)).await?; + Ok(ft) + } + + fn local(&self, name: &str) -> PathBuf { + self.local.path().join(name) + } + + /// Run a shell script in the sandbox and require it to succeed. + async fn sh(&self, script: &str) -> testkit::Result { + let output = self.sandbox.exec(["sh", "-c", script]).await?; + testkit::ensure!( + output.exit_code == 0, + "`{script}` exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + Ok(()) + } + + async fn upload( + &self, + source: &Path, + destination: &str, + no_git_ignore: bool, + ) -> testkit::Result { + let mut args = vec![ + "sandbox".to_string(), + "upload".to_string(), + self.sandbox.name().to_string(), + source.display().to_string(), + destination.to_string(), + ]; + if no_git_ignore { + args.push("--no-git-ignore".to_string()); + } + self.cx.cli(args).await + } + + async fn download(&self, source: &str, destination: &Path) -> testkit::Result { + self.cx + .cli([ + "sandbox", + "download", + self.sandbox.name(), + source, + &destination.display().to_string(), + ]) + .await + } + + /// Upload into existing directories, including a symlink to one and one + /// named with a trailing slash; the file must land inside each. Check the + /// destination's type, not only the exit status: tar can succeed after + /// replacing an empty directory with a file. + async fn upload_into_existing_directories(&self) -> testkit::Result { + let source = self.local("config.json"); + write(&source, "upload-destination-payload")?; + let root = format!("{}/upload destinations", self.remote_root); + self.sh(&format!( + "mkdir -p '{root}/empty' '{root}/populated' '{root}/target' '{root}/slash' && \ + printf keep > '{root}/populated/keep.txt' && \ + ln -s target '{root}/link'" + )) + .await?; + + for directory in ["empty", "populated", "link", "slash/"] { + let destination = format!("{root}/{directory}"); + self.upload(&source, &destination, true).await?.success()?; + self.sh(&format!( + "test -d '{destination}' && \ + test \"$(cat '{destination}/config.json')\" = upload-destination-payload && \ + test \"$(cat '{root}/populated/keep.txt')\" = keep && \ + test -L '{root}/link'" + )) + .await?; + } + + // A missing destination is a rename; uploading again must replace + // that file rather than treat it as a directory. + let renamed = format!("{root}/renamed.json"); + for _ in ["rename", "overwrite"] { + self.upload(&source, &renamed, true).await?.success()?; + self.sh(&format!( + "test -f '{renamed}' && test \"$(cat '{renamed}')\" = upload-destination-payload" + )) + .await?; + } + Ok(()) + } + + /// A directory with a nested file and a large binary file, and a single + /// file, survive upload then download. + async fn round_trip(&self) -> testkit::Result { + let source = self.local("roundtrip-upload"); + create_dir_all(&source.join("subdir"))?; + write(&source.join("greeting.txt"), "hello-from-local")?; + write(&source.join("subdir/nested.txt"), "nested-content")?; + let large: Vec = (0u8..=250).cycle().take(LARGE_FILE_SIZE).collect(); + fs::write(source.join("large.bin"), &large) + .map_err(|error| Error::msg(format!("write large.bin: {error}")))?; + + let remote = format!("{}/roundtrip", self.remote_root); + let uploaded = self.upload(&source, &remote, false).await?.success()?; + let warning = message(&uploaded); + testkit::ensure!( + warning.contains("outside a Git work tree") + && warning.contains(".gitignore rules are not applied"), + "upload outside a Git work tree did not warn that filtering is off: {warning}" + ); + + let destination = self.local("roundtrip-download"); + create_dir_all(&destination)?; + self.download(&format!("{remote}/roundtrip-upload"), &destination) + .await? + .success()?; + require_text(&destination.join("greeting.txt"), "hello-from-local")?; + require_text(&destination.join("subdir/nested.txt"), "nested-content")?; + let actual = fs::read(destination.join("large.bin")) + .map_err(|error| Error::msg(format!("read large.bin: {error}")))?; + testkit::ensure!( + actual == large, + "large file changed in the round trip: sent {} bytes, received {}", + large.len(), + actual.len() + ); + + let single = self.local("single.txt"); + write(&single, "single-file-payload")?; + let remote_single = format!("{}/single.txt", self.remote_root); + self.upload(&single, &remote_single, false) + .await? + .success()?; + let single_destination = self.local("single-download"); + create_dir_all(&single_destination)?; + self.download(&remote_single, &single_destination) + .await? + .success()?; + require_text( + &single_destination.join("single.txt"), + "single-file-payload", + ) + } + + async fn download_file(&self) -> testkit::Result { + let remote = format!("{}/download-file.txt", self.remote_root); + self.sh(&format!("printf greeting-payload > '{remote}'")) + .await?; + let destination = self.local("download-file"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("download-file.txt"), "greeting-payload") + } + + async fn download_directory(&self) -> testkit::Result { + let remote = format!("{}/tree", self.remote_root); + self.sh(&format!( + "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && \ + printf nested > '{remote}/sub/child.txt'" + )) + .await?; + let destination = self.local("download-directory"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("root.txt"), "top-level")?; + require_text(&destination.join("sub/child.txt"), "nested") + } + + async fn gitignore_filtering(&self) -> testkit::Result { + let repository = self.local("filter-repo"); + create_dir_all(&repository.join("build"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\nbuild/\n")?; + write(&repository.join("tracked.txt"), "i-am-tracked")?; + write(&repository.join("ignored.log"), "i-should-be-filtered")?; + write(&repository.join("build/output.bin"), "build-artifact")?; + git(&repository, &["add", "."])?; + + let remote = format!("{}/filtered", self.remote_root); + self.upload(&repository, &remote, false).await?.success()?; + let destination = self.local("filter-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + let uploaded = destination.join("filter-repo"); + require_text(&uploaded.join("tracked.txt"), "i-am-tracked")?; + require_exists(&uploaded.join(".gitignore"))?; + require_absent(&uploaded.join("ignored.log"))?; + require_absent(&uploaded.join("build")) + } + + async fn single_file_from_git_repo(&self) -> testkit::Result { + let repository = self.local("single-repo"); + create_dir_all(&repository.join("nested"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\n")?; + write( + &repository.join("nested/config.txt"), + "single-file-from-repo", + )?; + write(&repository.join("tracked.txt"), "should-not-upload")?; + write(&repository.join("ignored.log"), "ignored")?; + + let remote = format!("{}/single-from-repo", self.remote_root); + self.upload(&repository.join("nested/config.txt"), &remote, false) + .await? + .success()?; + let destination = self.local("single-repo-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("config.txt"), "single-file-from-repo")?; + require_absent(&destination.join("tracked.txt"))?; + require_absent(&destination.join("ignored.log")) + } + + async fn ignored_directory_requires_override(&self) -> testkit::Result { + let remote_seed = format!("{}/runs/test.json", self.remote_root); + self.sh(&format!( + "mkdir -p '{}/runs' && printf downloaded-payload > '{remote_seed}'", + self.remote_root + )) + .await?; + + let repository = self.local("override-repo"); + let runs = repository.join("runs"); + create_dir_all(&runs)?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "runs/\n")?; + self.download(&remote_seed, &runs).await?.success()?; + require_exists(&runs.join("test.json"))?; + + let remote = format!("{}/reuploaded", self.remote_root); + let rejected = self.upload(&runs, &remote, false).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("filtering selected no files") && output.contains("--no-git-ignore"), + "the rejected upload did not explain the --no-git-ignore override: {output}" + ); + self.sh(&format!("test ! -e '{remote}'")).await?; + + self.upload(&runs, &remote, true).await?.success()?; + let destination = self.local("override-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("runs/test.json"), "downloaded-payload") + } + + async fn reject_workspace_escape(&self) -> testkit::Result { + let etc_link = format!("{}/etc-link", self.remote_root); + let passwd_link = format!("{}/passwd-link", self.remote_root); + self.sh(&format!( + "ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'" + )) + .await?; + let destination = self.local("workspace-escape"); + create_dir_all(&destination)?; + + for source in [etc_link.clone(), passwd_link, format!("{etc_link}/passwd")] { + let rejected = self.download(&source, &destination).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("resolves to") + && output.contains("outside the") + && output.contains("sandbox workspace"), + "download of {source:?} was not rejected as outside the workspace: {output}" + ); + } + require_absent(&destination.join("passwd"))?; + require_absent(&destination.join("etc-link")) + } + + async fn download_dash_leading_name(&self) -> testkit::Result { + let remote = format!("{}/--checkpoint-action=evil", self.remote_root); + self.sh(&format!("printf dash-payload > '{remote}'")) + .await?; + let destination = self.local("dash-leading"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text( + &destination.join("--checkpoint-action=evil"), + "dash-payload", + ) + } +} + +/// The CLI's stdout and stderr as one line of words. Error reports wrap to +/// the terminal width and draw a gutter, so match against this instead. +fn message(output: &CliOutput) -> String { + format!("{} {}", output.stdout, output.stderr) + .replace(['│', '×', '╰', '─', '╭', '├'], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn git(repository: &Path, args: &[&str]) -> testkit::Result { + let output = std::process::Command::new("git") + .args(args) + .current_dir(repository) + .output() + .map_err(|error| Error::msg(format!("run git {}: {error}", args.join(" "))))?; + testkit::ensure!( + output.status.success(), + "git {} failed in {}: {}", + args.join(" "), + repository.display(), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +fn write(path: &Path, contents: &str) -> testkit::Result { + fs::write(path, contents) + .map_err(|error| Error::msg(format!("write {}: {error}", path.display()))) +} + +fn create_dir_all(path: &Path) -> testkit::Result { + fs::create_dir_all(path) + .map_err(|error| Error::msg(format!("create {}: {error}", path.display()))) +} + +fn require_text(path: &Path, expected: &str) -> testkit::Result { + let actual = fs::read_to_string(path) + .map_err(|error| Error::msg(format!("read {}: {error}", path.display())))?; + testkit::ensure!( + actual == expected, + "{} contains {actual:?}; expected {expected:?}", + path.display() + ); + Ok(()) +} + +fn require_exists(path: &Path) -> testkit::Result { + testkit::ensure!(path.exists(), "{} does not exist", path.display()); + Ok(()) +} + +fn require_absent(path: &Path) -> testkit::Result { + testkit::ensure!(!path.exists(), "{} exists but should not", path.display()); + Ok(()) +} diff --git a/tests/suites/conformance/policy-advisor/Cargo.toml b/tests/suites/conformance/policy-advisor/Cargo.toml new file mode 100644 index 0000000000..8cd3f8f4bc --- /dev/null +++ b/tests/suites/conformance/policy-advisor/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance-policy-advisor" +description = "Conformance suite: policy-advisor" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +openshell-testkit = { path = "../../../testkit/testkit" } +serde_json = "1" +tokio = { version = "1", features = ["time"] } diff --git a/tests/suites/conformance/policy-advisor/tests/policy_advisor.rs b/tests/suites/conformance/policy-advisor/tests/policy_advisor.rs new file mode 100644 index 0000000000..b494add4f7 --- /dev/null +++ b/tests/suites/conformance/policy-advisor/tests/policy_advisor.rs @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Conformance suite: policy-advisor. +//! +//! The sandbox's `policy.local` API and the draft policy a denied connection +//! produces. Requests to `policy.local` run inside the sandbox; settings and +//! drafts go through the gateway's gRPC API. + +use std::time::{Duration, Instant}; + +use serde_json::Value; +use testkit::sdk::raw::proto; +use testkit::{Context, Error, Sandbox}; + +const READY_TIMEOUT: Duration = Duration::from_mins(4); +const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(90); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const PROPOSALS_ENABLED: &str = "agent_policy_proposals_enabled"; + +/// With proposals enabled, `policy.local` serves the current policy and +/// recent denials, accepts a proposed rule, reports its state, and the rule +/// reaches the reviewer's draft policy. +#[testkit::test(id = "t-p2jo0tvz")] +async fn policy_local_accepts_proposals(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "pl", None).await?; + enable_proposals(&sandbox).await?; + let binary = bash_path(&sandbox).await?; + + // The current-policy route is local; proposal lookup also needs the + // supervisor's session with the gateway, so wait for both. + let started = Instant::now(); + let readiness = format!("/v1/proposals/test-{}-readiness", cx.run_id()); + loop { + let ready = async { + let current = policy_local(&sandbox, "GET", "/v1/policy/current", "", 200).await?; + testkit::ensure!( + current["format"] == "yaml" + && current["policy_yaml"] + .as_str() + .is_some_and(|yaml| yaml.contains("version: 1")), + "policy.local returned an invalid current policy: {current}" + ); + let lookup = policy_local(&sandbox, "GET", &readiness, "", 404).await?; + testkit::ensure!( + lookup["error"] == "chunk_not_found", + "policy.local proposal lookup returned {lookup}" + ); + Ok(()) + } + .await; + match ready { + Ok(()) => break, + Err(error) if started.elapsed() >= READY_TIMEOUT => return Err(error), + Err(_) => tokio::time::sleep(POLL_INTERVAL).await, + } + } + + let denials = policy_local(&sandbox, "GET", "/v1/denials?last=1", "", 200).await?; + testkit::ensure!( + denials["denials"].is_array() && denials["log_available"].is_boolean(), + "policy.local returned an invalid denials response: {denials}" + ); + + let rule_name = format!("conformance_local_{}", cx.run_id()); + let payload = serde_json::json!({ + "intent_summary": "Allow Bash to read the conformance path on example.invalid.", + "operations": [{ + "addRule": { + "ruleName": &rule_name, + "rule": { + "name": &rule_name, + "endpoints": [{ + "host": "example.invalid", + "port": 443, + "protocol": "rest", + "enforcement": "enforce", + "rules": [{"allow": {"method": "GET", "path": "/conformance"}}] + }], + "binaries": [{"path": &binary}] + } + } + }] + }) + .to_string(); + let submitted = policy_local(&sandbox, "POST", "/v1/proposals", &payload, 202).await?; + let chunk_id = submitted["accepted_chunk_ids"] + .as_array() + .filter(|ids| ids.len() == 1) + .and_then(|ids| ids[0].as_str()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + Error::msg(format!( + "policy.local did not accept one proposal: {submitted}" + )) + })? + .to_string(); + testkit::ensure!( + submitted["status"] == "submitted" + && submitted["accepted_chunks"] == 1 + && submitted["rejected_chunks"] == 0, + "policy.local did not submit one rule: {submitted}" + ); + + let state = policy_local( + &sandbox, + "GET", + &format!("/v1/proposals/{chunk_id}"), + "", + 200, + ) + .await?; + testkit::ensure!( + state["chunk_id"] == chunk_id.as_str() + && state["rule_name"] == rule_name.as_str() + && state["binary"] == binary.as_str() + && matches!(state["status"].as_str(), Some("pending" | "approved")), + "policy.local returned the wrong proposal: {state}" + ); + + let draft = sandbox.draft_policy().await?; + testkit::ensure!( + draft + .chunks + .iter() + .any(|chunk| chunk.id == chunk_id && chunk.rule_name == rule_name), + "the submitted rule {rule_name} ({chunk_id}) is not in the draft policy: {:?}", + draft + .chunks + .iter() + .map(|chunk| (&chunk.id, &chunk.rule_name)) + .collect::>() + ); + Ok(()) +} + +/// In a sandbox with no network rules, a denied connection to an IP address +/// produces one mechanistic L4 draft rule for that endpoint and binary. +#[testkit::test(id = "t-gpw7sqdx")] +async fn denied_address_produces_draft_rule(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "mp", Some(no_network_policy())).await?; + enable_proposals(&sandbox).await?; + + let config = sandbox.config().await?; + let rules = config + .policy + .as_ref() + .map(|policy| policy.network_policies.len()) + .unwrap_or_default(); + testkit::ensure!( + rules == 0, + "the effective policy has {rules} network rules; expected none" + ); + + let probe = sandbox + .exec([ + "bash", + "-c", + "printf 'BINARY=%s\\n' \"$(readlink -f /proc/$$/exe)\"; \ + if exec 3<>/dev/tcp/1.1.1.1/443; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await?; + testkit::ensure!( + probe.exit_code == 0 && probe.stdout.lines().any(|line| line == "DENIED"), + "the connection to 1.1.1.1:443 was not denied: stdout {:?}, stderr {:?}", + probe.stdout, + probe.stderr + ); + let binary = probe + .stdout + .lines() + .find_map(|line| line.strip_prefix("BINARY=")) + .filter(|binary| is_bash_path(binary)) + .ok_or_else(|| Error::msg(format!("no Bash path in probe output: {:?}", probe.stdout)))? + .to_string(); + + expect_draft( + &sandbox, + "allow_1_1_1_1_443", + ("1.1.1.1", 443), + &binary, + &probe.stderr, + ) + .await +} + +/// In a sandbox with no network rules, a denied connection to a new hostname +/// stays denied and produces one mechanistic L4 draft rule for it. +#[testkit::test(id = "t-mchrkrfm")] +async fn denied_hostname_produces_draft_rule(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "nh", Some(no_network_policy())).await?; + let binary = bash_path(&sandbox).await?; + + let probe = sandbox + .exec([ + "bash", + "-c", + "if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await?; + testkit::ensure!( + probe.exit_code == 0 && probe.stdout.lines().any(|line| line == "DENIED"), + "the connection to pypi.org:80 was not denied: stdout {:?}, stderr {:?}", + probe.stdout, + probe.stderr + ); + + expect_draft( + &sandbox, + "allow_pypi_org_80", + ("pypi.org", 80), + &binary, + &probe.stderr, + ) + .await +} + +async fn create( + cx: &Context, + suffix: &str, + policy: Option, +) -> testkit::Result { + let mut builder = cx + .sandbox(suffix) + .command(["sh", "-c", "exec sleep infinity"]); + if let Some(policy) = policy { + builder = builder.policy(policy); + } + let sandbox = builder.create().await?; + sandbox.wait_ready().await?; + Ok(sandbox) +} + +/// Filesystem access the tests need, and no network rules. +fn no_network_policy() -> proto::SandboxPolicy { + let paths = |paths: &[&str]| paths.iter().map(ToString::to_string).collect(); + proto::SandboxPolicy { + version: 1, + filesystem: Some(proto::FilesystemPolicy { + include_workdir: true, + read_only: paths(&[ + "/usr", + "/bin", + "/lib", + "/lib64", + "/proc", + "/dev/urandom", + "/app", + "/etc", + "/var/log", + ]), + read_write: paths(&["/sandbox", "/tmp", "/dev/null"]), + }), + landlock: Some(proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + ..proto::SandboxPolicy::default() + } +} + +/// Enable proposals for the sandbox and check the effective value, which a +/// global override could change. +async fn enable_proposals(sandbox: &Sandbox) -> testkit::Result { + sandbox + .set_setting( + PROPOSALS_ENABLED, + proto::setting_value::Value::BoolValue(true), + ) + .await?; + let config = sandbox.config().await?; + let value = config + .settings + .get(PROPOSALS_ENABLED) + .and_then(|setting| setting.value.as_ref()) + .and_then(|value| value.value.clone()); + testkit::ensure!( + value == Some(proto::setting_value::Value::BoolValue(true)), + "effective {PROPOSALS_ENABLED} is {value:?}; check for a global override" + ); + Ok(()) +} + +async fn bash_path(sandbox: &Sandbox) -> testkit::Result { + let output = sandbox + .exec([ + "bash", + "-c", + "printf '%s\\n' \"$(readlink -f /proc/$$/exe)\"", + ]) + .await?; + let path = output.stdout.trim(); + testkit::ensure!( + output.exit_code == 0 && is_bash_path(path), + "expected one absolute Bash path; got {:?}, stderr {:?}", + output.stdout, + output.stderr + ); + Ok(path.to_string()) +} + +fn is_bash_path(path: &str) -> bool { + path.starts_with('/') && !path.contains('\n') && path.rsplit('/').next() == Some("bash") +} + +/// Send an HTTP request to `policy.local` from inside the sandbox and parse +/// the JSON body, requiring `expected_status`. +async fn policy_local( + sandbox: &Sandbox, + method: &str, + path: &str, + body: &str, + expected_status: u16, +) -> testkit::Result { + const SCRIPT: &str = "method=$1; path=$2; body=$3; exec 3<>/dev/tcp/policy.local/80 || exit 1; \ + printf '%s %s HTTP/1.1\\r\\nHost: policy.local\\r\\nContent-Type: application/json\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n' \"$method\" \"$path\" \"${#body}\" >&3; \ + printf '%s' \"$body\" >&3; cat <&3"; + let output = sandbox + .exec([ + "bash", + "-c", + SCRIPT, + "policy-local-http", + method, + path, + body, + ]) + .await?; + testkit::ensure!( + output.exit_code == 0, + "{method} http://policy.local{path} exited {}; stderr: {}", + output.exit_code, + output.stderr + ); + let (headers, json) = output.stdout.split_once("\r\n\r\n").ok_or_else(|| { + Error::msg(format!( + "{method} http://policy.local{path} returned no complete HTTP response: {:?}", + output.stdout + )) + })?; + testkit::ensure!( + headers.starts_with(&format!("HTTP/1.1 {expected_status} ")) + && headers.lines().any(|line| { + line.to_ascii_lowercase() + .starts_with("content-type: application/json") + }), + "{method} http://policy.local{path} did not return {expected_status} with JSON: {headers}" + ); + serde_json::from_str(json).map_err(|error| { + Error::msg(format!( + "{method} http://policy.local{path} returned invalid JSON ({error}): {json}" + )) + }) +} + +/// Wait for the draft policy to contain a chunk, then require exactly one: +/// pending or approved, for `rule`, scoped to `binary` and an L4 `endpoint`. +async fn expect_draft( + sandbox: &Sandbox, + rule: &str, + endpoint: (&str, u32), + binary: &str, + probe_stderr: &str, +) -> testkit::Result { + let started = Instant::now(); + let chunks = loop { + match sandbox.draft_policy().await { + Ok(draft) if !draft.chunks.is_empty() => break draft.chunks, + Ok(_) | Err(_) if started.elapsed() < PROPOSAL_TIMEOUT => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Ok(_) => { + return Err(Error::msg(format!( + "no draft rule for {}:{} after {PROPOSAL_TIMEOUT:?}; probe stderr: {probe_stderr}", + endpoint.0, endpoint.1 + ))); + } + Err(error) => return Err(error), + } + }; + + testkit::ensure!( + chunks.len() == 1, + "expected one draft rule; got {chunks:#?}" + ); + let chunk = &chunks[0]; + let proposed = chunk.proposed_rule.as_ref(); + let endpoints: Vec<(&str, u32, &str)> = proposed + .map(|rule| { + rule.endpoints + .iter() + .map(|e| (e.host.as_str(), e.port, e.protocol.as_str())) + .collect() + }) + .unwrap_or_default(); + let binaries: Vec<&str> = proposed + .map(|rule| rule.binaries.iter().map(|b| b.path.as_str()).collect()) + .unwrap_or_default(); + testkit::ensure!( + matches!(chunk.status.as_str(), "pending" | "approved") + && chunk.rule_name == rule + && chunk.binary == binary + && binaries == [binary] + // An empty protocol is an L4 rule. + && endpoints == [(endpoint.0, endpoint.1, "")] + && chunk.rationale.contains(&format!("{}:{}", endpoint.0, endpoint.1)), + "expected one pending or approved L4 draft rule {rule} for {binary} and {}:{}; got {chunk:#?}", + endpoint.0, + endpoint.1 + ); + Ok(()) +} diff --git a/tests/suites/conformance/sandbox-lifecycle/Cargo.toml b/tests/suites/conformance/sandbox-lifecycle/Cargo.toml new file mode 100644 index 0000000000..28ea01f225 --- /dev/null +++ b/tests/suites/conformance/sandbox-lifecycle/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance-sandbox-lifecycle" +description = "Conformance suite: sandbox-lifecycle" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +openshell-testkit = { path = "../../../testkit/testkit" } diff --git a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs new file mode 100644 index 0000000000..cd7468eb28 --- /dev/null +++ b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Conformance suite: sandbox-lifecycle. + +use testkit::Context; +use testkit::sdk::{SandboxPhase, SandboxRestartPolicy}; + +/// A long-running sandbox becomes ready, runs a command with exec, and is gone +/// from get and list after delete. +#[testkit::test(id = "t-3tfdx240")] +async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("lr") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + let marker = format!("openshell-conformance-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0, + "exec exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + testkit::ensure!( + output.stdout == format!("{marker}\n"), + "exec stdout was {:?}; expected {:?}", + output.stdout, + format!("{marker}\n") + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A sandbox whose main command exits 0, with restart policy Never, reaches +/// Completed with exit code 0 and is not restarted. It is gone from get and +/// list after delete. +#[testkit::test(id = "t-nyiaw3g8")] +async fn run_to_completion_exit_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rtc") + .command(["sh", "-c", "exit 0"]) + .restart_policy(SandboxRestartPolicy::Never) + .create() + .await?; + + let finished = sandbox.wait_finished().await?; + testkit::ensure!( + finished.phase == SandboxPhase::Completed, + "sandbox finished in phase {:?} with exit code {:?}; expected Completed", + finished.phase, + finished.exit_code + ); + testkit::ensure!( + finished.exit_code == Some(0), + "exit code was {:?}; expected Some(0)", + finished.exit_code + ); + testkit::ensure!( + finished.restart_count == 0, + "restart count was {}; expected 0", + finished.restart_count + ); + + // Completed is final under Never: a later read must not show a restart. + let again = sandbox.get().await?; + testkit::ensure!( + again.phase == SandboxPhase::Completed && again.restart_count == 0, + "sandbox left Completed: phase {:?}, restart count {}", + again.phase, + again.restart_count + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A sandbox created with the gateway's default command becomes Ready, is +/// listed as Ready, runs a command with exec, and is gone after delete. +#[testkit::test(id = "t-cum9h58h")] +async fn default_command_sandbox_is_listed_ready(cx: &mut Context) -> testkit::Result { + let sandbox = cx.sandbox("dc").create().await?; + let ready = sandbox.wait_ready().await?; + testkit::ensure!( + ready.name == sandbox.name(), + "get returned sandbox {:?}; expected {:?}", + ready.name, + sandbox.name() + ); + cx.expect_listed(&sandbox, SandboxPhase::Ready).await?; + + let marker = format!("openshell-conformance-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == format!("{marker}\n"), + "exec exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A stopped sandbox rejects exec, accepts a second stop, and returns to +/// Ready when started. +#[testkit::test(id = "t-aco01yxf")] +async fn restart_stopped_sandbox(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rs") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + sandbox.stop().await?; + match sandbox.exec(["true"]).await { + Err(_) => {} + Ok(output) => testkit::ensure!( + output.exit_code != 0, + "exec succeeded while the sandbox was stopped" + ), + } + // Stop is idempotent. + let again = sandbox.stop().await?; + testkit::ensure!( + again.phase == SandboxPhase::Stopped, + "a second stop left the sandbox {:?}", + again.phase + ); + + sandbox.start().await?; + let output = sandbox.exec(["echo", "started"]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == "started\n", + "exec after start exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + Ok(()) +} + +/// Files in the workspace survive stop and start, and start runs the main +/// command again. +#[testkit::test(id = "t-lyu3wcxl", requires = [persistent_workspace])] +async fn stopped_sandbox_keeps_workspace(cx: &mut Context) -> testkit::Result { + const SENTINEL: &str = "/sandbox/.openshell-stop-start-sentinel"; + const RUN_COUNT: &str = "/sandbox/.openshell-main-run-count"; + let main = format!( + "count=0; test ! -f '{RUN_COUNT}' || count=$(cat '{RUN_COUNT}'); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > '{RUN_COUNT}'; \ + exec sleep infinity" + ); + let sandbox = cx + .sandbox("kw") + .command(["sh", "-c", main.as_str()]) + .create() + .await?; + sandbox.wait_ready().await?; + + let sentinel = format!("openshell-stop-start-{}", cx.run_id()); + let write = sandbox + .exec([ + "sh", + "-c", + &format!("printf '%s\\n' '{sentinel}' > '{SENTINEL}' && sync"), + ]) + .await?; + testkit::ensure!( + write.exit_code == 0, + "writing the sentinel exited {}; stderr: {}", + write.exit_code, + write.stderr + ); + + sandbox.stop().await?; + sandbox.start().await?; + + let read = sandbox.exec(["cat", SENTINEL]).await?; + testkit::ensure!( + read.stdout == format!("{sentinel}\n"), + "sentinel after restart is {:?}; stderr: {}", + read.stdout, + read.stderr + ); + let runs = sandbox.exec(["cat", RUN_COUNT]).await?; + testkit::ensure!( + runs.stdout == "2\n", + "main command ran {:?} times; expected 2", + runs.stdout.trim() + ); + Ok(()) +} + +/// A stopped sandbox can be deleted, and is then gone from get and list. +#[testkit::test(id = "t-n63qv7x9")] +async fn stopped_sandbox_can_be_deleted(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("sd") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + sandbox.stop().await?; + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock new file mode 100644 index 0000000000..ad5dff7cb7 --- /dev/null +++ b/tests/testkit/Cargo.lock @@ -0,0 +1,2896 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown 0.17.0", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror 2.0.20", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.20", +] + +[[package]] +name = "openshell-sdk" +version = "0.0.0" +dependencies = [ + "async-stream", + "async-trait", + "futures", + "hyper", + "hyper-util", + "miette", + "oauth2", + "openshell-core", + "reqwest", + "rustls", + "serde", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "openshell-testkit" +version = "0.0.0" +dependencies = [ + "futures", + "noyalib", + "openshell-sdk", + "openshell-testkit-macros", + "rand 0.9.4", + "serde", + "serde_json", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-testkit-macros" +version = "0.0.0" +dependencies = [ + "openshell-testkit-spec", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/testkit/Cargo.toml b/tests/testkit/Cargo.toml new file mode 100644 index 0000000000..5eff75f9ea --- /dev/null +++ b/tests/testkit/Cargo.toml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Shared support for the test families under tests/suites/. Families depend +# on these crates by path; the runner uses testkit-spec to build its catalog. +[workspace] +resolver = "2" +members = ["testkit", "testkit-macros", "testkit-spec"] diff --git a/tests/testkit/testkit-macros/Cargo.toml b/tests/testkit/testkit-macros/Cargo.toml new file mode 100644 index 0000000000..1a6c1d839a --- /dev/null +++ b/tests/testkit/testkit-macros/Cargo.toml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit-macros" +description = "The #[testkit::test] attribute for OpenShell end-to-end test suites" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit_macros" +proc-macro = true + +[dependencies] +openshell-testkit-spec = { path = "../testkit-spec" } +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs new file mode 100644 index 0000000000..eca2cdd8cd --- /dev/null +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The `#[testkit::test]` attribute. +//! +//! A test is an `async fn` taking `&mut testkit::Context` and +//! returning `testkit::Result`. Its doc comment is the test's description and +//! is required. `id` is the required stable test ID: random, never changed, +//! and kept when the test is renamed or moved. A missing ID's compile error +//! suggests a fresh one. Requirements are named after target capabilities: +//! +//! ```ignore +//! /// An exposed loopback HTTP service is reachable and goes away after delete. +//! #[testkit::test(id = "t-4f8k2m0q", requires = [service_exposure])] +//! async fn expose_service(cx: &mut Context) -> testkit::Result { /* ... */ } +//! ``` +//! +//! The attribute expands to a `#[tokio::test]` wrapper with the same name, so +//! the nextest test name is the function name. The original function is nested in +//! the wrapper and passed to `testkit::run`. Parsing lives in `testkit-spec`, +//! which the test runner's catalog also uses. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{Ident, ItemFn, LitStr}; + +#[proc_macro_attribute] +pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { + expand(attr.into(), item.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let mut body: ItemFn = syn::parse2(item)?; + let spec = testkit_spec::parse(attr, &body)?; + + let name = spec.name; + let name_str = LitStr::new(&name.to_string(), name.span()); + let id = LitStr::new(&spec.id, name.span()); + let description = LitStr::new(&spec.description, name.span()); + let requires = spec.requires.iter().map(|capability| { + let variant = Ident::new(&to_camel_case(&capability.to_string()), capability.span()); + quote!(::testkit::Capability::#variant) + }); + + let vis = body.vis.clone(); + let doc_attrs: Vec<_> = body + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .cloned() + .collect(); + body.attrs.retain(|attr| !attr.path().is_ident("doc")); + body.vis = syn::Visibility::Inherited; + body.sig.ident = format_ident!("body"); + + Ok(quote! { + #(#doc_attrs)* + #[::testkit::__private::tokio::test(crate = "::testkit::__private::tokio")] + #vis async fn #name() { + #body + ::testkit::run( + ::testkit::TestInfo { + id: #id, + name: #name_str, + description: #description, + requires: &[#(#requires),*], + }, + body, + ) + .await; + } + }) +} + +fn to_camel_case(snake: &str) -> String { + snake + .split('_') + .map(|word| { + let mut chars = word.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect() + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::expand; + + fn expand_str(attr: &str, item: &str) -> syn::Result { + expand(attr.parse().unwrap(), item.parse().unwrap()).map(|tokens| tokens.to_string()) + } + + #[test] + fn expands_to_wrapper_with_same_name() { + let out = expand_str( + "id = \"t-0123abcd\"", + "/// Does a thing.\nasync fn does_a_thing(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert!(out.contains("async fn does_a_thing ()"), "{out}"); + assert!(out.contains("async fn body (cx : & mut Context)"), "{out}"); + assert!(out.contains("id : \"t-0123abcd\""), "{out}"); + assert!(out.contains("name : \"does_a_thing\""), "{out}"); + assert!(out.contains("description : \"Does a thing.\""), "{out}"); + } + + #[test] + fn maps_requirements_to_capabilities() { + let out = expand_str( + "id = \"t-0123abcd\", requires = [service_exposure, gpu]", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert!( + out.contains( + "requires : & [:: testkit :: Capability :: ServiceExposure , :: testkit :: Capability :: Gpu]" + ), + "{out}" + ); + } + + #[test] + fn reports_spec_errors() { + let err = expand_str( + "", + "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } +} diff --git a/tests/testkit/testkit-spec/Cargo.toml b/tests/testkit/testkit-spec/Cargo.toml new file mode 100644 index 0000000000..640503c38a --- /dev/null +++ b/tests/testkit/testkit-spec/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit-spec" +description = "Parses #[testkit::test] functions for the macro and the test catalog" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit_spec" + +[dependencies] +proc-macro2 = "1" +syn = { version = "2", features = ["full"] } diff --git a/tests/testkit/testkit-spec/src/lib.rs b/tests/testkit/testkit-spec/src/lib.rs new file mode 100644 index 0000000000..c9084b6afa --- /dev/null +++ b/tests/testkit/testkit-spec/src/lib.rs @@ -0,0 +1,348 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The single definition of what a `#[testkit::test]` function declares. +//! +//! The `#[testkit::test]` macro and the test runner's catalog both parse +//! tests through this crate, so they cannot disagree about a test's ID, +//! name, description, or requirements. + +use std::hash::{BuildHasher, Hasher}; + +use proc_macro2::TokenStream; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Attribute, Expr, ExprLit, Ident, Item, ItemFn, Lit, LitStr, Meta, Token, bracketed}; + +/// Prefix of every test ID. +pub const ID_PREFIX: &str = "t-"; +/// Number of random characters after the prefix. +const ID_LEN: usize = 8; +const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + +/// What a test declares. +#[derive(Clone, Debug)] +pub struct TestSpec { + /// The stable test ID from `id = "..."`. It never changes, including when + /// the test is renamed or moved to another suite or family. + pub id: String, + /// The function name, which is also the nextest test name. + pub name: Ident, + /// The doc comment, joined into one line. + pub description: String, + /// Capability names, as written in `requires = [...]`. + pub requires: Vec, +} + +/// Arguments accepted by `#[testkit::test(...)]`. +#[derive(Default)] +struct Args { + id: Option, + requires: Vec, +} + +impl Parse for Args { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::()?; + if key == "id" { + if args.id.is_some() { + return Err(syn::Error::new(key.span(), "duplicate argument `id`")); + } + args.id = Some(input.parse()?); + } else if key == "requires" { + let content; + bracketed!(content in input); + args.requires + .extend(Punctuated::::parse_terminated(&content)?); + } else { + return Err(syn::Error::new( + key.span(), + format!( + "unknown argument `{key}`; expected `id = \"...\"` or `requires = [...]`" + ), + )); + } + if !input.is_empty() { + input.parse::()?; + } + } + Ok(args) + } +} + +/// Parse a test from the attribute's arguments and the function it marks. +pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { + let args: Args = syn::parse2(args)?; + + if item.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + item.sig.fn_token, + "testkit tests must be `async fn`", + )); + } + if item.sig.inputs.len() != 1 { + return Err(syn::Error::new_spanned( + &item.sig.inputs, + "testkit tests take exactly one argument: `cx: &mut testkit::Context`", + )); + } + + let description = doc_comment(&item.attrs); + if description.is_empty() { + return Err(syn::Error::new( + item.sig.ident.span(), + "testkit tests need a doc comment describing the behavior they verify", + )); + } + + let Some(id) = args.id else { + return Err(syn::Error::new( + item.sig.ident.span(), + format!( + "testkit tests need a stable `id`; for a new test use `id = \"{}\"`", + generate_id() + ), + )); + }; + if !is_valid_id(&id.value()) { + return Err(syn::Error::new( + id.span(), + format!( + "test IDs are `{ID_PREFIX}` followed by {ID_LEN} lowercase letters or digits; for a new test use `id = \"{}\"`", + generate_id() + ), + )); + } + + Ok(TestSpec { + id: id.value(), + name: item.sig.ident.clone(), + description, + requires: args.requires, + }) +} + +/// Whether `id` has the test ID form: the prefix and lowercase base-36 characters. +pub fn is_valid_id(id: &str) -> bool { + id.strip_prefix(ID_PREFIX).is_some_and(|rest| { + rest.len() == ID_LEN + && rest + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// A fresh random test ID, offered in the error for a missing or invalid one. +/// Uses the standard library's per-process random hash keys, so this crate +/// needs no RNG dependency. +pub fn generate_id() -> String { + let mut hasher = std::collections::hash_map::RandomState::new().build_hasher(); + hasher.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()), + ); + let mut value = hasher.finish(); + let mut id = String::from(ID_PREFIX); + for _ in 0..ID_LEN { + id.push(char::from(ID_ALPHABET[(value % 36) as usize])); + value /= 36; + } + id +} + +/// Find every `#[testkit::test]` function in a source file. +pub fn scan(source: &str) -> syn::Result> { + let file = syn::parse_file(source)?; + let mut tests = Vec::new(); + for item in &file.items { + let Item::Fn(item) = item else { + continue; + }; + let Some(attr) = item.attrs.iter().find(|attr| is_testkit_test(attr)) else { + continue; + }; + let args = match &attr.meta { + Meta::Path(_) => TokenStream::new(), + Meta::List(list) => list.tokens.clone(), + Meta::NameValue(meta) => { + return Err(syn::Error::new_spanned( + meta, + "expected `#[testkit::test]` or `#[testkit::test(...)]`", + )); + } + }; + tests.push(parse(args, item)?); + } + Ok(tests) +} + +fn is_testkit_test(attr: &Attribute) -> bool { + let segments: Vec<_> = attr.path().segments.iter().collect(); + segments.len() == 2 && segments[0].ident == "testkit" && segments[1].ident == "test" +} + +/// The doc comment, joined into one line. Empty if there is none. +pub fn doc_comment(attrs: &[Attribute]) -> String { + attrs + .iter() + .filter_map(|attr| match &attr.meta { + Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { + Expr::Lit(ExprLit { + lit: Lit::Str(line), + .. + }) => Some(line.value().trim().to_string()), + _ => None, + }, + _ => None, + }) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(args: &str, item: &str) -> syn::Result { + parse(args.parse().unwrap(), &syn::parse_str(item).unwrap()) + } + + const ID: &str = "id = \"t-0123abcd\""; + + #[test] + fn parses_id_name_description_and_requirements() { + let spec = parse_str( + "id = \"t-0123abcd\", requires = [service_exposure, gpu]", + "/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert_eq!(spec.id, "t-0123abcd"); + assert_eq!(spec.name, "t"); + assert_eq!(spec.description, "First line second line."); + assert_eq!(spec.requires, ["service_exposure", "gpu"]); + } + + #[test] + fn rejects_missing_id_and_suggests_one() { + let err = parse_str( + "", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("need a stable `id`"), "{err}"); + let suggested = err.split('"').nth(1).unwrap(); + assert!(is_valid_id(suggested), "{err}"); + } + + #[test] + fn rejects_malformed_id() { + for id in [ + "t-0123ABCD", + "t-0123abc", + "x-0123abcd", + "0123abcd", + "t-0123abcde", + ] { + let err = parse_str( + &format!("id = \"{id}\""), + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("test IDs are"), "{id}: {err}"); + } + } + + #[test] + fn rejects_duplicate_id_argument() { + let err = parse_str( + "id = \"t-0123abcd\", id = \"t-0123abce\"", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("duplicate argument"), "{err}"); + } + + #[test] + fn generated_ids_are_valid_and_distinct() { + let a = generate_id(); + let b = generate_id(); + assert!(is_valid_id(&a), "{a}"); + assert_ne!(a, b); + } + + #[test] + fn rejects_missing_doc_comment() { + let err = parse_str( + ID, + "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } + + #[test] + fn rejects_sync_fn() { + let err = parse_str( + ID, + "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("async fn"), "{err}"); + } + + #[test] + fn rejects_wrong_arity() { + let err = + parse_str(ID, "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); + assert!(err.to_string().contains("exactly one argument"), "{err}"); + } + + #[test] + fn rejects_unknown_argument() { + let err = parse_str( + "id = \"t-0123abcd\", timeout = [x]", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("unknown argument"), "{err}"); + } + + #[test] + fn scans_only_testkit_tests() { + let tests = scan( + " + use testkit::Context; + + /// Plain. + #[testkit::test(id = \"t-00000001\")] + async fn plain(cx: &mut Context) -> testkit::Result { Ok(()) } + + /// Needs exposure. + #[testkit::test(id = \"t-00000002\", requires = [service_exposure])] + async fn exposed(cx: &mut Context) -> testkit::Result { Ok(()) } + + #[test] + fn not_a_testkit_test() {} + + async fn helper(cx: &mut Context) {} + ", + ) + .unwrap(); + let names: Vec<_> = tests.iter().map(|test| test.name.to_string()).collect(); + assert_eq!(names, ["plain", "exposed"]); + assert_eq!(tests[1].id, "t-00000002"); + assert_eq!(tests[1].requires, ["service_exposure"]); + } + + #[test] + fn scan_reports_invalid_tests() { + let err = scan("#[testkit::test]\nasync fn t(cx: &mut Context) {}").unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } +} diff --git a/tests/testkit/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml new file mode 100644 index 0000000000..7e1f4a2210 --- /dev/null +++ b/tests/testkit/testkit/Cargo.toml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit" +description = "Shared support for OpenShell end-to-end test suites" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit" + +[dependencies] +openshell-sdk = { path = "../../../crates/openshell-sdk" } +openshell-testkit-macros = { path = "../testkit-macros" } +futures = "0.3" +rand = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } +tokio = { version = "1.43", features = ["macros", "process", "rt", "time"] } +tonic = { version = "0.14", features = ["tls-native-roots"] } diff --git a/tests/testkit/testkit/src/bin/testkit-ready.rs b/tests/testkit/testkit/src/bin/testkit-ready.rs new file mode 100644 index 0000000000..5d4e8aea1f --- /dev/null +++ b/tests/testkit/testkit/src/bin/testkit-ready.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Wait until the target's gateway is ready for tests. +//! +//! Usage: `testkit-ready [TIMEOUT_SECS]` (default 60), with the target file +//! named by `OPENSHELL_TEST_TARGET`. Run it once per environment before the +//! test binaries; exits non-zero with the last error if the gateway is not +//! healthy in time, or if the environment's CLI does not reach it. + +use std::process::ExitCode; +use std::time::Duration; + +use testkit::Target; + +const DEFAULT_TIMEOUT_SECS: u64 = 60; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let timeout = match std::env::args().nth(1).map(|arg| arg.parse::()) { + None => DEFAULT_TIMEOUT_SECS, + Some(Ok(secs)) => secs, + Some(Err(error)) => { + eprintln!("testkit-ready: invalid timeout: {error}"); + return ExitCode::from(2); + } + }; + let result = async { + let target = Target::from_env()?; + let health = target.wait_ready(Duration::from_secs(timeout)).await?; + target.check_cli().await?; + testkit::Result::Ok(health) + } + .await; + match result { + Ok(health) => { + eprintln!( + "testkit-ready: gateway {} is healthy and the CLI reaches it", + health.version + ); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("testkit-ready: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/tests/testkit/testkit/src/cli.rs b/tests/testkit/testkit/src/cli.rs new file mode 100644 index 0000000000..10f6c9168c --- /dev/null +++ b/tests/testkit/testkit/src/cli.rs @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Driving the `openshell` CLI the environment installed. +//! +//! Some promised behavior exists only in the CLI: file upload and download, +//! interactive connect, and port forwarding. Tests act through the CLI for +//! those and observe through the SDK where they can. Rely on the CLI's exit +//! code and structured output (`--output json`), not on its table text. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::error::{Error, Result, ResultExt}; +use crate::target::CliTarget; + +/// How long one CLI invocation may take. +pub const CLI_TIMEOUT: Duration = Duration::from_mins(5); + +/// Runs the target's CLI against the target's gateway. +#[derive(Clone, Debug)] +pub struct Cli { + binary: PathBuf, + gateway: Option, +} + +/// The buffered result of one CLI invocation. +#[derive(Debug)] +pub struct CliOutput { + pub command: String, + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +impl Cli { + pub(crate) fn new(target: &CliTarget, binary: PathBuf) -> Self { + Self { + binary, + gateway: target.gateway.clone(), + } + } + + /// Run the CLI with `args` and buffer its output. A non-zero exit is + /// returned, not raised; use [`CliOutput::success`] to require success. + pub async fn run(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.run_in(None, args).await + } + + /// Like [`Cli::run`], with `dir` as the working directory. + pub async fn run_in(&self, dir: Option<&Path>, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let args: Vec<_> = args + .into_iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect(); + let command = std::iter::once(self.binary.as_os_str()) + .chain(args.iter().map(std::ffi::OsString::as_os_str)) + .map(|part| part.to_string_lossy()) + .collect::>() + .join(" "); + + let mut process = tokio::process::Command::new(&self.binary); + process + .args(&args) + .env("NO_COLOR", "1") + // Select the target's gateway, never one from the caller's + // environment. + .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .stdin(Stdio::null()) + .kill_on_drop(true); + match &self.gateway { + Some(gateway) => process.env("OPENSHELL_GATEWAY", gateway), + None => process.env_remove("OPENSHELL_GATEWAY"), + }; + if let Some(dir) = dir { + process.current_dir(dir); + } + + let output = tokio::time::timeout(CLI_TIMEOUT, process.output()) + .await + .map_err(|_| Error::msg(format!("timed out after {CLI_TIMEOUT:?}"))) + .context(format!("run {command}"))? + .map_err(|error| Error::from_error(&error)) + .context(format!("run {command}"))?; + Ok(CliOutput { + command, + // A signal-terminated CLI has no code; report it as a failure. + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } +} + +impl CliOutput { + /// Fail unless the command exited 0. + pub fn success(self) -> Result { + crate::ensure!( + self.exit_code == 0, + "`{}` exited with {}; stderr: {}", + self.command, + self.exit_code, + self.stderr.trim() + ); + Ok(self) + } + + /// Fail unless the command exited non-zero, e.g. when a request must be + /// rejected. + pub fn failure(self) -> Result { + crate::ensure!( + self.exit_code != 0, + "`{}` succeeded but was expected to fail; stdout: {}", + self.command, + self.stdout.trim() + ); + Ok(self) + } + + /// Parse stdout as JSON, from a command run with `--output json`. + pub fn json(&self) -> Result { + serde_json::from_str(&self.stdout) + .map_err(|error| Error::from_error(&error)) + .context(format!("parse JSON output of `{}`", self.command)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cli(binary: &str) -> Cli { + Cli { + binary: PathBuf::from(binary), + gateway: Some("tmachine".to_string()), + } + } + + #[tokio::test] + async fn runs_with_the_target_gateway_selected() { + let output = cli("sh") + .run(["-c", "echo \"$OPENSHELL_GATEWAY\"; exit 3"]) + .await + .unwrap(); + assert_eq!(output.stdout, "tmachine\n"); + assert_eq!(output.exit_code, 3); + let err = output.success().unwrap_err(); + assert!(err.to_string().contains("exited with 3"), "{err}"); + } + + #[tokio::test] + async fn parses_json_output() { + let output = cli("sh") + .run(["-c", "echo '{\"status\": \"connected\"}'"]) + .await + .unwrap() + .success() + .unwrap(); + let value: serde_json::Value = output.json().unwrap(); + assert_eq!(value["status"], "connected"); + } + + #[tokio::test] + async fn reports_a_missing_binary() { + let err = cli("/nonexistent/openshell") + .run(["status"]) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("run /nonexistent/openshell status"), + "{err}" + ); + } +} diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs new file mode 100644 index 0000000000..f6a0404bcc --- /dev/null +++ b/tests/testkit/testkit/src/context.rs @@ -0,0 +1,536 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-test context: the SDK client, the run ID, and the resources to clean up. + +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use openshell_sdk::raw::{AuthedGrpcClient, proto}; +use openshell_sdk::{ + DeleteOptions, ExecOptions, ListOptions, OpenShellClient, SandboxPhase, SandboxRef, + SandboxRestartPolicy, SandboxSpec, SdkError, WorkspaceScopedClient, +}; +use rand::Rng; + +use crate::cli::{Cli, CliOutput}; +use crate::error::{Error, Result, ResultExt}; +use crate::poll::{Poll, poll_until}; + +/// Sandbox creation includes image pulls on a cold environment. +pub const READY_TIMEOUT: Duration = Duration::from_mins(10); +pub const DELETE_TIMEOUT: Duration = Duration::from_mins(2); +pub const EXEC_TIMEOUT: Duration = Duration::from_mins(2); +/// Stopping includes stopping compute, which can take a while on some drivers. +pub const STOP_TIMEOUT: Duration = Duration::from_mins(5); +const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2); +const STATUS_INTERVAL: Duration = Duration::from_secs(1); + +/// Names of sandboxes created by a test and not yet confirmed deleted. +type Registry = Arc>>; + +/// What a test uses to act on the system under test. +pub struct Context { + client: WorkspaceScopedClient, + grpc: Grpc, + cli: Result, + run_id: String, + sandboxes: Registry, +} + +impl Context { + pub(crate) fn new(client: &OpenShellClient, workspace: &str, cli: Result) -> Self { + Self { + client: client.workspace(workspace), + grpc: Grpc { + client: client.clone(), + workspace: workspace.to_string(), + }, + cli, + run_id: generate_run_id(), + sandboxes: Registry::default(), + } + } + + /// Run the environment's `openshell` CLI against the target's gateway. + /// A non-zero exit is returned, not raised. + pub async fn cli(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.cli_driver()?.run(args).await + } + + /// Like [`Context::cli`], with `dir` as the working directory. + pub async fn cli_in(&self, dir: &std::path::Path, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.cli_driver()?.run_in(Some(dir), args).await + } + + fn cli_driver(&self) -> Result<&Cli> { + self.cli + .as_ref() + .map_err(|error| Error::msg(error.to_string())) + } + + /// The ID unique to this test run, used in resource names. + pub fn run_id(&self) -> &str { + &self.run_id + } + + /// The SDK client, for operations testkit does not wrap. + pub fn client(&self) -> &WorkspaceScopedClient { + &self.client + } + + /// A resource name unique to this run: `test--`. + pub fn name(&self, suffix: &str) -> String { + format!("test-{}-{suffix}", self.run_id) + } + + /// Start describing a sandbox named `cx.name(suffix)`. + pub fn sandbox(&self, suffix: &str) -> SandboxBuilder { + SandboxBuilder { + client: self.client.clone(), + grpc: self.grpc.clone(), + policy: None, + sandboxes: Arc::clone(&self.sandboxes), + spec: SandboxSpec { + name: Some(self.name(suffix)), + ..SandboxSpec::default() + }, + } + } + + /// Fail unless the sandbox is gone from both get and list. + pub async fn expect_absent(&self, sandbox: &Sandbox) -> Result { + let name = sandbox.name(); + match self.client.get_sandbox(name).await { + Err(SdkError::NotFound { .. }) => {} + Err(error) => return Err(Error::from(error).context(format!("get sandbox {name}"))), + Ok(found) => { + return Err(Error::msg(format!( + "sandbox {name} still exists (id {}, phase {:?})", + found.id, found.phase + ))); + } + } + let listed = self + .client + .list_all_sandboxes(ListOptions::default()) + .await + .context("list sandboxes")?; + crate::ensure!( + !listed.iter().any(|entry| entry.name == name), + "sandbox {name} is absent from get but still listed" + ); + Ok(()) + } + + /// Fail unless `list` shows the sandbox in `phase`. + pub async fn expect_listed(&self, sandbox: &Sandbox, phase: SandboxPhase) -> Result { + let name = sandbox.name(); + let listed = self + .client + .list_all_sandboxes(ListOptions::default()) + .await + .context("list sandboxes")?; + let entry = listed.iter().find(|entry| entry.name == name); + crate::ensure!(entry.is_some(), "sandbox {name} is not listed"); + let entry = entry.expect("checked above"); + crate::ensure!( + entry.phase == phase, + "sandbox {name} is listed in phase {:?}; expected {phase:?}", + entry.phase + ); + Ok(()) + } + + /// Delete every sandbox the test created and has not deleted. Returns the + /// cleanup failures; they are reported separately from the test result. + pub(crate) async fn cleanup(&self) -> Vec { + let started = Instant::now(); + let names: Vec = lock(&self.sandboxes).iter().cloned().collect(); + let mut failures = Vec::new(); + for name in names { + let remaining = CLEANUP_TIMEOUT.saturating_sub(started.elapsed()); + let result = async { + self.client + .delete_sandbox( + &name, + DeleteOptions { + allow_missing: true, + }, + ) + .await?; + self.client.wait_deleted(&name, remaining, None).await + } + .await; + match result { + Ok(()) => { + lock(&self.sandboxes).remove(&name); + } + Err(error) => failures.push(format!("delete sandbox {name}: {error}")), + } + } + failures + } +} + +/// The generated gRPC client, for public API the SDK does not wrap yet. +#[derive(Clone)] +struct Grpc { + client: OpenShellClient, + workspace: String, +} + +impl Grpc { + async fn client(&self) -> Result { + Ok(self.client.raw_grpc_fresh().await?) + } + + fn scope(&self) -> Option { + Some(proto::workspace_selector(&self.workspace)) + } +} + +/// A sandbox to create. +pub struct SandboxBuilder { + client: WorkspaceScopedClient, + grpc: Grpc, + policy: Option, + sandboxes: Registry, + spec: SandboxSpec, +} + +impl SandboxBuilder { + /// The sandbox's main command. Empty selects the gateway's default. + #[must_use] + pub fn command(mut self, command: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.spec.command = command.into_iter().map(Into::into).collect(); + self + } + + #[must_use] + pub fn restart_policy(mut self, policy: SandboxRestartPolicy) -> Self { + self.spec.restart_policy = policy; + self + } + + /// Create the sandbox with this policy instead of the gateway's default. + #[must_use] + pub fn policy(mut self, policy: proto::SandboxPolicy) -> Self { + self.policy = Some(policy); + self + } + + /// Full access to the spec for fields without a builder method. + #[must_use] + pub fn spec(mut self, update: impl FnOnce(&mut SandboxSpec)) -> Self { + update(&mut self.spec); + self + } + + /// Create the sandbox. It is tracked for cleanup before the request is + /// sent, so a create that fails after the gateway accepted it is still + /// cleaned up. + pub async fn create(self) -> Result { + let name = self.spec.name.clone().unwrap_or_default(); + lock(&self.sandboxes).insert(name.clone()); + let id = match self.policy { + None => { + self.client + .create_sandbox(self.spec) + .await + .context(format!("create sandbox {name}"))? + .id + } + // The SDK's spec has no policy field, so create through gRPC. + Some(policy) => { + crate::ensure!( + self.spec.image.is_none() + && self.spec.environment.is_empty() + && self.spec.providers.is_empty() + && !self.spec.gpu + && self.spec.service_exposures.is_empty(), + "testkit creates a sandbox with a policy from its name, command, restart policy, and policy only" + ); + let request = proto::CreateSandboxRequest { + name: name.clone(), + workspace_scope: self.grpc.scope(), + spec: Some(proto::SandboxSpec { + command: self.spec.command, + tty: self.spec.tty, + policy: Some(policy), + restart_policy: restart_policy(self.spec.restart_policy) as i32, + ..proto::SandboxSpec::default() + }), + ..proto::CreateSandboxRequest::default() + }; + self.grpc + .client() + .await? + .create_sandbox(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("create sandbox {name}"))?; + self.client + .get_sandbox(&name) + .await + .context(format!("get sandbox {name}"))? + .id + } + }; + Ok(Sandbox { + client: self.client, + grpc: self.grpc, + sandboxes: self.sandboxes, + name, + id, + }) + } +} + +const fn restart_policy(policy: SandboxRestartPolicy) -> proto::SandboxRestartPolicy { + match policy { + SandboxRestartPolicy::Never => proto::SandboxRestartPolicy::Never, + SandboxRestartPolicy::OnFailure => proto::SandboxRestartPolicy::OnFailure, + SandboxRestartPolicy::Always => proto::SandboxRestartPolicy::Always, + } +} + +/// A sandbox created by the test. +pub struct Sandbox { + client: WorkspaceScopedClient, + grpc: Grpc, + sandboxes: Registry, + name: String, + id: String, +} + +/// The buffered output of an exec. +#[derive(Debug)] +pub struct ExecOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +impl Sandbox { + pub fn name(&self) -> &str { + &self.name + } + + pub fn id(&self) -> &str { + &self.id + } + + /// Wait until the sandbox is Ready. A sandbox that completes or fails + /// instead is an error. + pub async fn wait_ready(&self) -> Result { + let snapshot = self + .client + .wait_ready(&self.name, READY_TIMEOUT) + .await + .context(format!("wait for sandbox {} to be ready", self.name))?; + crate::ensure!( + snapshot.phase == SandboxPhase::Ready, + "sandbox {} reached phase {:?}; expected Ready", + self.name, + snapshot.phase + ); + Ok(snapshot) + } + + /// Wait until the main process has finished: the sandbox is Completed or + /// in Error. Returns that snapshot; the test decides which outcome it + /// expects. Includes provisioning, so it uses the ready timeout. + pub async fn wait_finished(&self) -> Result { + poll_until( + &format!("sandbox {} to finish", self.name), + READY_TIMEOUT, + STATUS_INTERVAL, + async || { + let snapshot = self.get().await?; + Ok(match snapshot.phase { + SandboxPhase::Completed | SandboxPhase::Error => Poll::Ready(snapshot), + phase => Poll::Pending(format!("phase {phase:?}")), + }) + }, + ) + .await + } + + /// Stop the sandbox and wait until it is Stopped. + pub async fn stop(&self) -> Result { + self.client + .stop_sandbox(&self.name) + .await + .context(format!("stop sandbox {}", self.name))?; + self.wait_phase(SandboxPhase::Stopped, STOP_TIMEOUT).await + } + + /// Start the sandbox and wait until it is Ready again. + pub async fn start(&self) -> Result { + self.client + .start_sandbox(&self.name) + .await + .context(format!("start sandbox {}", self.name))?; + self.wait_ready().await + } + + /// Wait until the sandbox reaches `phase`. + pub async fn wait_phase(&self, phase: SandboxPhase, timeout: Duration) -> Result { + poll_until( + &format!("sandbox {} to be {phase:?}", self.name), + timeout, + STATUS_INTERVAL, + async || { + let snapshot = self.get().await?; + Ok(if snapshot.phase == phase { + Poll::Ready(snapshot) + } else { + Poll::Pending(format!("phase {:?}", snapshot.phase)) + }) + }, + ) + .await + } + + /// Set a sandbox-scoped setting. + pub async fn set_setting(&self, key: &str, value: proto::setting_value::Value) -> Result { + let request = proto::UpdateConfigRequest { + sandbox: self.name.clone(), + workspace_scope: self.grpc.scope(), + setting_key: key.to_string(), + setting_value: Some(proto::SettingValue { value: Some(value) }), + ..proto::UpdateConfigRequest::default() + }; + self.grpc + .client() + .await? + .update_config(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("set {key} on sandbox {}", self.name))?; + Ok(()) + } + + /// The sandbox's effective policy and settings. + pub async fn config(&self) -> Result { + let request = proto::GetSandboxConfigRequest { + name: self.name.clone(), + workspace_scope: self.grpc.scope(), + }; + Ok(self + .grpc + .client() + .await? + .get_sandbox_config(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("get config of sandbox {}", self.name))? + .into_inner()) + } + + /// The sandbox's draft policy chunks, all statuses. + pub async fn draft_policy(&self) -> Result { + let request = proto::GetDraftPolicyRequest { + sandbox: self.name.clone(), + workspace_scope: self.grpc.scope(), + status_filter: String::new(), + }; + Ok(self + .grpc + .client() + .await? + .get_draft_policy(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("get draft policy of sandbox {}", self.name))? + .into_inner()) + } + + /// The sandbox's current state. + pub async fn get(&self) -> Result { + self.client + .get_sandbox(&self.name) + .await + .context(format!("get sandbox {}", self.name)) + } + + /// Run a command in the sandbox and buffer its output. + pub async fn exec(&self, command: I) -> Result + where + I: IntoIterator, + S: Into, + { + let command: Vec = command.into_iter().map(Into::into).collect(); + let options = ExecOptions { + timeout: Some(EXEC_TIMEOUT), + ..ExecOptions::default() + }; + let result = self + .client + .exec(&self.name, &command, options) + .await + .context(format!("exec {command:?} in sandbox {}", self.name))?; + Ok(ExecOutput { + exit_code: result.exit_code, + stdout: String::from_utf8_lossy(&result.stdout).into_owned(), + stderr: String::from_utf8_lossy(&result.stderr).into_owned(), + }) + } + + /// Delete the sandbox and wait until it is gone. + pub async fn delete(&self) -> Result { + self.client + .delete_sandbox(&self.name, DeleteOptions::default()) + .await + .context(format!("delete sandbox {}", self.name))?; + self.client + .wait_deleted(&self.name, DELETE_TIMEOUT, Some(&self.id)) + .await + .context(format!("wait for sandbox {} to be deleted", self.name))?; + lock(&self.sandboxes).remove(&self.name); + Ok(()) + } +} + +fn lock(registry: &Registry) -> std::sync::MutexGuard<'_, BTreeSet> { + registry.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Short, lowercase, and DNS-label safe, so names work on every driver. +fn generate_run_id() -> String { + const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::rng(); + (0..8) + .map(|_| char::from(ALPHABET[rng.random_range(0..ALPHABET.len())])) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_ids_are_dns_label_safe() { + let id = generate_run_id(); + assert_eq!(id.len(), 8); + assert!( + id.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()), + "{id}" + ); + } +} diff --git a/tests/testkit/testkit/src/error.rs b/tests/testkit/testkit/src/error.rs new file mode 100644 index 0000000000..dad7fe35ad --- /dev/null +++ b/tests/testkit/testkit/src/error.rs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test failures. + +use std::fmt; + +use openshell_sdk::SdkError; + +/// A test failure: a message with the context it happened in. +#[derive(Debug)] +pub struct Error { + message: String, +} + +/// The result of a test or test step. +pub type Result = std::result::Result; + +impl Error { + pub fn msg(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn from_display(error: impl fmt::Display) -> Self { + Self::msg(error.to_string()) + } + + /// An error with its sources, so a wrapper such as tonic's + /// "transport error" still says what went wrong underneath. + pub fn from_error(error: &(dyn std::error::Error + 'static)) -> Self { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let cause_message = cause.to_string(); + // Some errors repeat their source's message in their own. + if !message.ends_with(&cause_message) { + message = format!("{message}: {cause_message}"); + } + source = cause.source(); + } + Self::msg(message) + } + + #[must_use] + pub fn context(self, context: impl fmt::Display) -> Self { + Self::msg(format!("{context}: {}", self.message)) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(error: SdkError) -> Self { + Self::from_error(&error) + } +} + +/// Add context to a failing step. +pub trait ResultExt { + fn context(self, context: impl fmt::Display) -> Result; +} + +impl> ResultExt for std::result::Result { + fn context(self, context: impl fmt::Display) -> Result { + self.map_err(|error| error.into().context(context)) + } +} + +/// Fail the test unless `cond` holds. +/// +/// ```ignore +/// testkit::ensure!(output.exit_code == 0, "exec exited with {}", output.exit_code); +/// ``` +#[macro_export] +macro_rules! ensure { + ($cond:expr $(,)?) => { + if !$cond { + return Err($crate::Error::msg(concat!("condition failed: ", stringify!($cond)))); + } + }; + ($cond:expr, $($arg:tt)+) => { + if !$cond { + return Err($crate::Error::msg(format!($($arg)+))); + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_prefixes_message() { + let result: Result = Err(Error::msg("boom")); + let err = result.context("step two").context("test").unwrap_err(); + assert_eq!(err.to_string(), "test: step two: boom"); + } + + #[derive(Debug)] + struct Wrapper(Option>, &'static str); + + impl fmt::Display for Wrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.1) + } + } + + impl std::error::Error for Wrapper { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.0.as_deref() + } + } + + #[test] + fn from_error_includes_sources() { + let refused = Wrapper(None, "connection refused"); + let transport = Wrapper(Some(Box::new(refused)), "transport error"); + assert_eq!( + Error::from_error(&transport).to_string(), + "transport error: connection refused" + ); + } + + #[test] + fn from_error_skips_repeated_source_messages() { + let inner = Wrapper(None, "refused"); + let outer = Wrapper(Some(Box::new(inner)), "connect: refused"); + assert_eq!(Error::from_error(&outer).to_string(), "connect: refused"); + } + + #[test] + fn ensure_returns_error_with_message() { + fn check(value: i32) -> Result { + ensure!(value == 1, "value was {value}"); + Ok(()) + } + assert!(check(1).is_ok()); + assert_eq!(check(2).unwrap_err().to_string(), "value was 2"); + } + + #[test] + fn ensure_without_message_names_condition() { + fn check(value: i32) -> Result { + ensure!(value == 1); + Ok(()) + } + assert_eq!( + check(2).unwrap_err().to_string(), + "condition failed: value == 1" + ); + } +} diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs new file mode 100644 index 0000000000..b54e0cf76a --- /dev/null +++ b/tests/testkit/testkit/src/lib.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared support for `OpenShell` end-to-end test suites. +//! +//! The suites under `tests/suites/` (conformance and the other families) test +//! a running `OpenShell` system through the Rust SDK. Each test is an +//! `async fn` marked `#[testkit::test]`: +//! +//! ```ignore +//! use testkit::Context; +//! +//! /// A long-running sandbox becomes ready, runs exec, and is gone after delete. +//! #[testkit::test(id = "t-3tfdx240")] +//! async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { +//! let sandbox = cx.sandbox("lr").command(["sleep", "infinity"]).create().await?; +//! sandbox.wait_ready().await?; +//! // ... +//! Ok(()) +//! } +//! ``` +//! +//! Tests read the system under test from the target file named by +//! `OPENSHELL_TEST_TARGET` (see [`target`]). + +mod cli; +mod context; +mod error; +mod poll; +mod run; +pub mod target; + +pub use cli::{CLI_TIMEOUT, Cli, CliOutput}; +pub use context::{ + Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, STOP_TIMEOUT, Sandbox, + SandboxBuilder, +}; +pub use error::{Error, Result, ResultExt}; +pub use openshell_sdk as sdk; +pub use poll::{Poll, poll_until}; +pub use run::{TestInfo, run}; +pub use target::{Capability, Target}; +pub use testkit_macros::test; + +#[doc(hidden)] +pub mod __private { + pub use tokio; +} diff --git a/tests/testkit/testkit/src/poll.rs b/tests/testkit/testkit/src/poll.rs new file mode 100644 index 0000000000..1f46f49c37 --- /dev/null +++ b/tests/testkit/testkit/src/poll.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Waiting for observable state. + +use std::time::{Duration, Instant}; + +use crate::error::{Error, Result}; + +/// The outcome of one observation. +pub enum Poll { + /// The condition holds. + Ready(T), + /// Not yet; the string describes what was observed, for the timeout report. + Pending(String), +} + +/// Observe until the condition holds or `timeout` elapses. +/// +/// An error from `observe` fails immediately. On timeout, the error reports +/// the last observation. +pub async fn poll_until( + what: &str, + timeout: Duration, + interval: Duration, + mut observe: impl AsyncFnMut() -> Result>, +) -> Result { + let started = Instant::now(); + loop { + match observe().await? { + Poll::Ready(value) => return Ok(value), + Poll::Pending(observation) if started.elapsed() >= timeout => { + return Err(Error::msg(format!( + "timed out after {timeout:?} waiting for {what}; last observation: {observation}" + ))); + } + Poll::Pending(_) => tokio::time::sleep(interval).await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn returns_when_ready() { + let mut calls = 0; + let value = poll_until( + "three calls", + Duration::from_secs(5), + Duration::ZERO, + async || { + calls += 1; + Ok(if calls == 3 { + Poll::Ready(calls) + } else { + Poll::Pending(format!("call {calls}")) + }) + }, + ) + .await + .unwrap(); + assert_eq!(value, 3); + } + + #[tokio::test] + async fn reports_last_observation_on_timeout() { + let err = poll_until::<()>("never", Duration::ZERO, Duration::ZERO, async || { + Ok(Poll::Pending("still waiting".to_string())) + }) + .await + .unwrap_err(); + assert!(err.to_string().contains("waiting for never"), "{err}"); + assert!(err.to_string().contains("still waiting"), "{err}"); + } + + #[tokio::test] + async fn fails_fast_on_error() { + let err = poll_until::<()>( + "error", + Duration::from_mins(1), + Duration::ZERO, + async || Err(Error::msg("broken")), + ) + .await + .unwrap_err(); + assert_eq!(err.to_string(), "broken"); + } +} diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs new file mode 100644 index 0000000000..d44acb806e --- /dev/null +++ b/tests/testkit/testkit/src/run.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Running one test: load the target, check requirements, run, clean up. + +use std::panic::AssertUnwindSafe; + +use futures::FutureExt; + +use crate::context::Context; +use crate::error::Result; +use crate::target::{Capability, Target}; + +/// Identity of a test, generated by `#[testkit::test]`. +#[derive(Clone, Copy, Debug)] +pub struct TestInfo { + /// The stable test ID. + pub id: &'static str, + /// The function name, which is also the nextest test name. + pub name: &'static str, + pub description: &'static str, + pub requires: &'static [Capability], +} + +/// Run a test body against the target named by `OPENSHELL_TEST_TARGET`. +/// +/// Cleanup runs whether the body passes, fails, or panics. The test fails if +/// the body fails, or if cleanup fails after the body passed. +/// +/// # Panics +/// +/// Panics to fail the test. +pub async fn run(info: TestInfo, body: F) +where + F: AsyncFnOnce(&mut Context) -> Result, +{ + let name = info.name; + eprintln!("[{name}] {} ({})", info.description, info.id); + + let target = Target::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); + let missing = missing_requirements(&info, &target); + // The runner excludes tests the target can't support. Reaching here means + // it didn't, so fail rather than report a pass for an untested behavior. + assert!( + missing.is_empty(), + "[{name}] requires {}, which the target does not provide", + missing.join(", ") + ); + + let client = target + .connect() + .await + .unwrap_or_else(|error| panic!("[{name}] {error}")); + let mut cx = Context::new(&client, &target.workspace, target.cli()); + eprintln!("[{name}] run id {}", cx.run_id()); + + let outcome = AssertUnwindSafe(body(&mut cx)).catch_unwind().await; + let cleanup_failures = cx.cleanup().await; + for failure in &cleanup_failures { + eprintln!("[{name}] cleanup failed: {failure}"); + } + + match outcome { + Err(panic) => std::panic::resume_unwind(panic), + Ok(Err(error)) => panic!("[{name}] {error}"), + Ok(Ok(())) => assert!( + cleanup_failures.is_empty(), + "[{name}] passed, but cleanup failed: {}", + cleanup_failures.join("; ") + ), + } +} + +fn missing_requirements(info: &TestInfo, target: &Target) -> Vec<&'static str> { + info.requires + .iter() + .filter(|capability| !target.capabilities.provides(**capability)) + .map(|capability| capability.name()) + .collect() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn reports_missing_requirements() { + let target = Target::parse( + "gateway:\n endpoint: http://x\ncapabilities:\n gpu: true\n", + PathBuf::new(), + ) + .unwrap(); + let info = TestInfo { + id: "t-00000000", + name: "t", + description: "d", + requires: &[Capability::Gpu, Capability::ServiceExposure], + }; + assert_eq!(missing_requirements(&info, &target), ["service_exposure"]); + } +} diff --git a/tests/testkit/testkit/src/target.rs b/tests/testkit/testkit/src/target.rs new file mode 100644 index 0000000000..f783d8f117 --- /dev/null +++ b/tests/testkit/testkit/src/target.rs @@ -0,0 +1,435 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The target: how tests reach the system under test, and what it provides. +//! +//! An environment writes a target file; tests find it through +//! [`TARGET_ENV`]. Relative paths in the file resolve against its directory. +//! +//! ```yaml +//! gateway: +//! endpoint: https://127.0.0.1:17670 +//! tls: +//! ca: ca.crt +//! client_cert: client.crt +//! client_key: client.key +//! cli: +//! binary: /usr/bin/openshell # default: `openshell` on PATH +//! gateway: tmachine # the name the gateway is registered under +//! workspace: default +//! capabilities: +//! compute_driver: docker +//! service_exposure: true +//! ``` + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use openshell_sdk::{ + AuthConfig, ClientConfig, EdgeAuthInterceptor, Health, OpenShellClient, ServiceStatus, +}; +use serde::Deserialize; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; + +use crate::cli::Cli; +use crate::error::{Error, Result, ResultExt}; + +/// Environment variable naming the target file. +pub const TARGET_ENV: &str = "OPENSHELL_TEST_TARGET"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Target { + pub gateway: Gateway, + /// The CLI the environment installed. Every environment provides one; + /// it is optional here only so SDK-only suites can run against a + /// hand-written target. + #[serde(default)] + pub cli: Option, + #[serde(default = "default_workspace")] + pub workspace: String, + #[serde(default)] + pub capabilities: Capabilities, + #[serde(skip)] + base_dir: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Gateway { + /// Gateway URL; the scheme selects plaintext (`http`) or TLS (`https`). + pub endpoint: String, + #[serde(default)] + pub tls: Option, + #[serde(default)] + pub auth: Option, +} + +/// TLS material. A client certificate and key together select mTLS. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Tls { + pub ca: Option, + pub client_cert: Option, + pub client_key: Option, +} + +/// The installed `openshell` CLI, configured for the target's gateway. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliTarget { + /// A path, resolved like other target paths, or a name found on PATH. + #[serde(default = "default_cli_binary")] + pub binary: PathBuf, + /// The gateway name to select. Omit to use the CLI's active gateway. + #[serde(default)] + pub gateway: Option, +} + +fn default_cli_binary() -> PathBuf { + PathBuf::from("openshell") +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Auth { + pub bearer_token_file: PathBuf, +} + +/// Facts about the environment, declared by the environment definition. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Capabilities { + pub arch: Option, + pub compute_driver: Option, + #[serde(default)] + pub rootless: bool, + #[serde(default)] + pub gpu: bool, + #[serde(default)] + pub service_exposure: bool, + /// Files in the sandbox workspace survive stop and start. The docs leave + /// this to the compute driver. + #[serde(default)] + pub persistent_workspace: bool, +} + +/// A capability a test can require with `#[testkit::test(requires = [...])]`. +/// +/// Variants are the snake-case requirement names in CamelCase, so an unknown +/// requirement fails to compile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Capability { + Gpu, + PersistentWorkspace, + ServiceExposure, +} + +impl Capability { + pub const fn name(self) -> &'static str { + match self { + Self::Gpu => "gpu", + Self::PersistentWorkspace => "persistent_workspace", + Self::ServiceExposure => "service_exposure", + } + } +} + +impl Capabilities { + pub const fn provides(&self, capability: Capability) -> bool { + match capability { + Capability::Gpu => self.gpu, + Capability::PersistentWorkspace => self.persistent_workspace, + Capability::ServiceExposure => self.service_exposure, + } + } +} + +fn default_workspace() -> String { + "default".to_string() +} + +// Mirrors the SDK's endpoint settings for channels testkit builds itself. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); +const READY_INTERVAL: Duration = Duration::from_secs(1); + +impl Target { + /// Load the target named by [`TARGET_ENV`]. + pub fn from_env() -> Result { + let path = std::env::var_os(TARGET_ENV).ok_or_else(|| { + Error::msg(format!( + "{TARGET_ENV} is not set; it must name the target file for the system under test" + )) + })?; + Self::from_path(Path::new(&path)) + } + + pub fn from_path(path: &Path) -> Result { + let yaml = std::fs::read_to_string(path) + .map_err(Error::from_display) + .context(format!("read target file {}", path.display()))?; + let base_dir = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + Self::parse(&yaml, base_dir).context(format!("load target file {}", path.display())) + } + + /// Parse a target, resolving relative paths against `base_dir`. + pub fn parse(yaml: &str, base_dir: PathBuf) -> Result { + let mut target: Self = serde_yml::from_str(yaml).map_err(Error::from_display)?; + target.base_dir = base_dir; + target.validate()?; + Ok(target) + } + + fn validate(&self) -> Result { + let gateway = &self.gateway; + let Some(tls) = &gateway.tls else { + return Ok(()); + }; + if tls.client_cert.is_some() != tls.client_key.is_some() { + return Err(Error::msg( + "gateway.tls.client_cert and gateway.tls.client_key must be set together", + )); + } + if !gateway.endpoint.starts_with("https://") { + return Err(Error::msg(format!( + "gateway.tls is set but gateway.endpoint {:?} is not https", + gateway.endpoint + ))); + } + Ok(()) + } + + fn resolve(&self, path: &Path) -> PathBuf { + self.base_dir.join(path) + } + + /// The target's CLI. Fails if the target file has no `cli` section. + pub fn cli(&self) -> Result { + let cli = self.cli.as_ref().ok_or_else(|| { + Error::msg("the target file has no `cli` section; the environment must provide its CLI") + })?; + // A bare name is looked up on PATH; anything with a separator is a + // path relative to the target file. + let binary = if cli.binary.components().count() > 1 { + self.resolve(&cli.binary) + } else { + cli.binary.clone() + }; + Ok(Cli::new(cli, binary)) + } + + /// Check that the target's CLI reaches the gateway: `openshell status` + /// must report it connected. + pub async fn check_cli(&self) -> Result { + let output = self + .cli()? + .run(["status", "--output", "json"]) + .await? + .success()?; + let status: serde_json::Value = output.json()?; + crate::ensure!( + status["status"] == "connected", + "`{}` reports {}", + output.command, + status + ); + Ok(()) + } + + fn read(&self, path: &Path) -> Result> { + let path = self.resolve(path); + std::fs::read(&path) + .map_err(Error::from_display) + .context(format!("read {}", path.display())) + } + + /// Connect to the target's gateway. + pub async fn connect(&self) -> Result { + let gateway = &self.gateway; + let tls = gateway.tls.as_ref(); + let ca = tls + .and_then(|tls| tls.ca.as_deref()) + .map(|path| self.read(path)) + .transpose()?; + let token = gateway + .auth + .as_ref() + .map(|auth| { + self.read(&auth.bearer_token_file) + .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string()) + }) + .transpose()?; + + let identity = match tls.map(|tls| (&tls.client_cert, &tls.client_key)) { + Some((Some(cert), Some(key))) => { + Some(Identity::from_pem(self.read(cert)?, self.read(key)?)) + } + _ => None, + }; + + let Some(identity) = identity else { + let mut config = ClientConfig::new(gateway.endpoint.clone()); + config.ca_cert = ca; + config.auth = token.map(AuthConfig::oidc); + return OpenShellClient::connect(config) + .await + .context(format!("connect to {}", gateway.endpoint)); + }; + + // The SDK does not speak mTLS, so build the channel here and hand it + // to the SDK. Every SDK call, including exec, uses this channel. + let tls_config = ca.map_or_else( + || ClientTlsConfig::new().with_enabled_roots(), + |ca| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)), + ); + let channel = Endpoint::from_shared(gateway.endpoint.clone()) + .map_err(|error| Error::from_error(&error))? + .connect_timeout(CONNECT_TIMEOUT) + .http2_adaptive_window(true) + .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL) + .keep_alive_while_idle(true) + .tls_config(tls_config.identity(identity)) + .map_err(|error| Error::from_error(&error)) + .context("configure mTLS")? + .connect() + .await + .map_err(|error| Error::from_error(&error)) + .context(format!("connect to {} with mTLS", gateway.endpoint))?; + let interceptor = EdgeAuthInterceptor::new(token.as_deref(), None)?; + Ok(OpenShellClient::from_parts(channel, interceptor)) + } + + /// Wait until the gateway accepts a connection and reports itself + /// healthy, retrying until `timeout`. + /// + /// This is a readiness check for whoever brings the environment up, run + /// once before its tests. Tests themselves connect once and fail fast. + pub async fn wait_ready(&self, timeout: Duration) -> Result { + let started = Instant::now(); + loop { + let error = match self.check_health().await { + Ok(health) => return Ok(health), + Err(error) => error, + }; + if started.elapsed() >= timeout { + return Err(error.context(format!( + "gateway at {} not ready after {}s", + self.gateway.endpoint, + timeout.as_secs() + ))); + } + tokio::time::sleep(READY_INTERVAL).await; + } + } + + async fn check_health(&self) -> Result { + let health = self.connect().await?.health().await.context("health")?; + crate::ensure!( + health.status == ServiceStatus::Healthy, + "gateway reports {:?}", + health.status + ); + Ok(health) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(yaml: &str) -> Result { + Target::parse(yaml, PathBuf::from("/targets/vm")) + } + + #[test] + fn parses_mtls_target_and_resolves_relative_paths() { + let target = parse( + "gateway:\n endpoint: https://127.0.0.1:17670\n tls:\n ca: ca.crt\n client_cert: client.crt\n client_key: /abs/client.key\ncapabilities:\n compute_driver: docker\n service_exposure: true\n", + ) + .unwrap(); + let tls = target.gateway.tls.as_ref().unwrap(); + assert_eq!(target.workspace, "default"); + assert_eq!( + target.resolve(tls.ca.as_deref().unwrap()), + PathBuf::from("/targets/vm/ca.crt") + ); + assert_eq!( + target.resolve(tls.client_key.as_deref().unwrap()), + PathBuf::from("/abs/client.key") + ); + assert!(target.capabilities.provides(Capability::ServiceExposure)); + assert!(!target.capabilities.provides(Capability::Gpu)); + } + + #[test] + fn parses_plaintext_target() { + let target = parse("gateway:\n endpoint: http://127.0.0.1:17670\n").unwrap(); + assert!(target.gateway.tls.is_none()); + assert!(!target.capabilities.provides(Capability::ServiceExposure)); + } + + #[test] + fn parses_cli_section() { + let target = parse( + "gateway:\n endpoint: http://x\ncli:\n binary: bin/openshell\n gateway: tmachine\n", + ) + .unwrap(); + let cli = target.cli.as_ref().unwrap(); + assert_eq!(cli.gateway.as_deref(), Some("tmachine")); + assert!(target.cli().is_ok()); + + let target = parse("gateway:\n endpoint: http://x\ncli: {}\n").unwrap(); + assert_eq!( + target.cli.as_ref().unwrap().binary, + PathBuf::from("openshell") + ); + + let err = parse("gateway:\n endpoint: http://x\n") + .unwrap() + .cli() + .unwrap_err(); + assert!(err.to_string().contains("no `cli` section"), "{err}"); + } + + #[test] + fn rejects_unknown_fields() { + let err = parse("gateway:\n endpoint: http://x\n endpiont: http://y\n").unwrap_err(); + assert!(err.to_string().contains("endpiont"), "{err}"); + } + + #[test] + fn rejects_half_configured_client_identity() { + let err = parse("gateway:\n endpoint: https://x\n tls:\n client_cert: client.crt\n") + .unwrap_err(); + assert!(err.to_string().contains("set together"), "{err}"); + } + + #[test] + fn rejects_tls_on_plaintext_endpoint() { + let err = parse("gateway:\n endpoint: http://x\n tls:\n ca: ca.crt\n").unwrap_err(); + assert!(err.to_string().contains("not https"), "{err}"); + } + + #[tokio::test] + async fn wait_ready_reports_the_last_error_after_timeout() { + // Nothing listens on a port bound and then released. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let target = parse(&format!("gateway:\n endpoint: http://127.0.0.1:{port}\n")).unwrap(); + let err = target.wait_ready(Duration::ZERO).await.unwrap_err(); + let message = err.to_string(); + assert!( + message.starts_with(&format!( + "gateway at http://127.0.0.1:{port} not ready after 0s: " + )), + "{message}" + ); + } +} diff --git a/tests/tmachine/Cargo.toml b/tests/tmachine/Cargo.toml index 46358ea4e0..785ee5ea91 100644 --- a/tests/tmachine/Cargo.toml +++ b/tests/tmachine/Cargo.toml @@ -18,6 +18,6 @@ directories = { version = "6.0.0", default-features = false } serde = { version = "1.0.229", default-features = false, features = ["std", "derive"] } serde-saphyr = { version = "1.2.0", default-features = false, features = ["deserialize"] } tempfile = { version = "3.27.0", default-features = false } -tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util"] } +tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util", "time"] } [workspace] diff --git a/tests/tmachine/src/lib.rs b/tests/tmachine/src/lib.rs new file mode 100644 index 0000000000..ff1db593bc --- /dev/null +++ b/tests/tmachine/src/lib.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Isolated QEMU test machines provisioned with Ansible. +//! +//! The `tmachine` binary drives one-shot `setup`, `install`, and `test` +//! commands. Callers that need to act on a running machine use [`Session`]. + +pub mod ansible; +pub mod config; +mod qemu; + +pub use qemu::{Session, install, setup, test}; diff --git a/tests/tmachine/src/main.rs b/tests/tmachine/src/main.rs index 3c0d1a4eee..69f9f006c1 100644 --- a/tests/tmachine/src/main.rs +++ b/tests/tmachine/src/main.rs @@ -5,11 +5,8 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use config::{Config, Environment, Installer, Machine}; - -mod ansible; -mod config; -mod qemu; +use tmachine::config::{Config, Environment, Installer, Machine}; +use tmachine::{install, setup, test}; #[derive(Parser)] #[command(name = "tmachine")] @@ -45,7 +42,7 @@ async fn main() -> Result<()> { match cli.command { Command::Setup { environment } => { let (machine, environment) = find_environment(&config, &environment)?; - qemu::setup(&machine, &environment).await?; + setup(&machine, &environment).await?; } Command::Install { environment, @@ -53,7 +50,7 @@ async fn main() -> Result<()> { } => { let (machine, environment) = find_environment(&config, &environment)?; let installer = find_installer(&config, &installer)?; - qemu::install(&machine, &environment, &installer).await?; + install(&machine, &environment, &installer).await?; } Command::Test { environment, @@ -67,7 +64,7 @@ async fn main() -> Result<()> { .iter() .find(|candidate| candidate.name == testsuite) .with_context(|| format!("testsuite {testsuite:?} is not defined"))?; - qemu::test(&machine, &environment, &installer, testsuite).await?; + test(&machine, &environment, &installer, testsuite).await?; } } diff --git a/tests/tmachine/src/qemu/mod.rs b/tests/tmachine/src/qemu/mod.rs index c13ebbf56b..5be890226c 100644 --- a/tests/tmachine/src/qemu/mod.rs +++ b/tests/tmachine/src/qemu/mod.rs @@ -5,10 +5,12 @@ mod ansible_hash; mod img; mod install; mod layer; +mod session; mod setup; mod test; mod vm; pub use install::install; +pub use session::Session; pub use setup::setup; pub use test::test; diff --git a/tests/tmachine/src/qemu/session.rs b/tests/tmachine/src/qemu/session.rs new file mode 100644 index 0000000000..25d4b74a14 --- /dev/null +++ b/tests/tmachine/src/qemu/session.rs @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! A running machine that callers act on over SSH. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Output, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail, ensure}; +use tempfile::{TempDir, tempdir}; +use tokio::process::Command; + +use crate::config::{Environment, Installer, Machine}; + +use super::img::QemuImage; +use super::install::install; +use super::layer::run_playbooks; +use super::vm::QemuVm; + +// Must match the QEMU port forward in `vm.rs` and `ansible/inventory.ini`. +const SSH_HOST: &str = "127.0.0.1"; +const SSH_PORT: &str = "2222"; +const SSH_USER: &str = "tmachine"; +const SSH_PASSWORD: &str = "tmachine"; +// Password authentication only: keys from the caller's agent or ~/.ssh would +// otherwise use up the guest's authentication attempts first. +const SSH_OPTIONS: [&str; 14] = [ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", + "-o", + "ConnectTimeout=5", + "-o", + "PreferredAuthentications=password", + "-o", + "PubkeyAuthentication=no", + "-o", + "IdentityAgent=none", +]; +const SSH_READY_TIMEOUT: Duration = Duration::from_secs(300); +const SSH_READY_INTERVAL: Duration = Duration::from_secs(2); + +/// A machine booted from a throwaway overlay of the installed disk. +/// +/// The VM stops when the session is dropped, so a caller that fails or +/// panics cannot leak it. Call [`Session::down`] for a clean shutdown. +pub struct Session { + // Declared before `_dir` so the VM stops before its disk is removed. + vm: Option, + _image: QemuImage, + _dir: TempDir, +} + +impl Session { + /// Set up and install the environment (reusing cached disks), boot a VM + /// from a throwaway overlay, and wait until it accepts SSH. + pub async fn up( + machine: &Machine, + environment: &Environment, + installer: &Installer, + ) -> Result { + let install_disk = install(machine, environment, installer).await?; + let dir = tempdir().context("create session directory")?; + let image = QemuImage::create(&install_disk, dir.path().join("session.qcow2")).await; + let vm = QemuVm::start(&image).await; + let mut session = Self { + vm: Some(vm), + _image: image, + _dir: dir, + }; + session.wait_for_ssh().await?; + Ok(session) + } + + async fn wait_for_ssh(&mut self) -> Result<()> { + let started = Instant::now(); + loop { + if let Some(status) = self.vm.as_mut().and_then(QemuVm::try_wait) { + bail!( + "QEMU exited with {status} before SSH was ready; is another tmachine VM using port {SSH_PORT}?" + ); + } + match self.exec(&["true"]).await { + Ok(output) if output.status.success() => return Ok(()), + Ok(_) | Err(_) if started.elapsed() < SSH_READY_TIMEOUT => { + tokio::time::sleep(SSH_READY_INTERVAL).await; + } + Ok(output) => bail!( + "SSH was not ready after {SSH_READY_TIMEOUT:?}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + Err(error) => { + return Err( + error.context(format!("SSH was not ready after {SSH_READY_TIMEOUT:?}")) + ); + } + } + } + } + + /// Run Ansible playbooks against the machine. + pub async fn run_playbooks( + &self, + playbooks: &[PathBuf], + inputs: &BTreeMap, + ) -> Result<()> { + run_playbooks(playbooks, inputs).await + } + + /// Run a command in the machine as the `tmachine` user and capture its + /// output. A non-zero exit is returned in the output, not as an error. + pub async fn exec(&self, argv: &[&str]) -> Result { + ensure!(!argv.is_empty(), "exec needs a command"); + let remote = argv + .iter() + .map(|arg| shell_quote(arg)) + .collect::>() + .join(" "); + ssh_command("ssh", &["-p", SSH_PORT]) + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .arg("--") + .arg(remote) + .stdin(Stdio::null()) + .output() + .await + .with_context(|| format!("run {argv:?} in tmachine VM")) + } + + /// Copy a local file or directory into the machine. + pub async fn copy_in(&self, src: &Path, dst: &str) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{dst}"); + scp(src.as_os_str().to_string_lossy().as_ref(), &remote) + .await + .with_context(|| format!("copy {} to {dst} in tmachine VM", src.display())) + } + + /// Copy a file or directory out of the machine. + pub async fn copy_out(&self, src: &str, dst: &Path) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{src}"); + scp(&remote, dst.as_os_str().to_string_lossy().as_ref()) + .await + .with_context(|| format!("copy {src} from tmachine VM to {}", dst.display())) + } + + /// Write the OpenShell gateway's journal to `dir/gateway.log`. + pub async fn collect_diagnostics(&self, dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("create diagnostics directory {}", dir.display()))?; + let output = self + .exec(&[ + "sudo", + "-n", + "journalctl", + "--no-pager", + "--lines", + "2000", + "_SYSTEMD_UNIT=openshell-gateway.service", + "+", + "_SYSTEMD_USER_UNIT=openshell-gateway.service", + ]) + .await?; + let log = dir.join("gateway.log"); + let mut contents = output.stdout; + contents.extend_from_slice(&output.stderr); + std::fs::write(&log, contents).with_context(|| format!("write {}", log.display())) + } + + /// Open an interactive SSH shell in the machine. + pub async fn shell(&self) -> Result { + ssh_command("ssh", &["-tt", "-p", SSH_PORT]) + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .context("open SSH shell in tmachine VM") + } + + /// Power the machine off and wait for QEMU to exit. + pub async fn down(mut self) { + if let Some(vm) = self.vm.take() { + vm.shutdown().await; + vm.wait().await; + } + } +} + +fn ssh_command(program: &str, args: &[&str]) -> Command { + let mut command = Command::new("sshpass"); + command + .env("SSHPASS", SSH_PASSWORD) + .arg("-e") + .arg(program) + .args(args) + .args(SSH_OPTIONS); + command +} + +async fn scp(src: &str, dst: &str) -> Result<()> { + let output = ssh_command("scp", &["-r", "-P", SSH_PORT]) + .arg(src) + .arg(dst) + .stdin(Stdio::null()) + .output() + .await + .context("run scp")?; + ensure!( + output.status.success(), + "scp exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +/// Quote an argument for the remote POSIX shell that SSH runs commands in. +fn shell_quote(arg: &str) -> String { + let safe = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%".contains(&b)); + if safe { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +#[cfg(test)] +mod tests { + use super::shell_quote; + + #[test] + fn leaves_plain_arguments_unquoted() { + assert_eq!(shell_quote("cargo-nextest"), "cargo-nextest"); + assert_eq!(shell_quote("/var/lib/x.tar.zst"), "/var/lib/x.tar.zst"); + assert_eq!(shell_quote("KEY=value"), "KEY=value"); + } + + #[test] + fn quotes_shell_metacharacters() { + assert_eq!( + shell_quote("binary(a) | binary(b)"), + "'binary(a) | binary(b)'" + ); + assert_eq!(shell_quote("$HOME"), "'$HOME'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn escapes_single_quotes() { + assert_eq!(shell_quote("it's"), r"'it'\''s'"); + } +} diff --git a/tests/tmachine/src/qemu/test.rs b/tests/tmachine/src/qemu/test.rs index ac8a8bdfc0..cd4cb95d48 100644 --- a/tests/tmachine/src/qemu/test.rs +++ b/tests/tmachine/src/qemu/test.rs @@ -1,18 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::process::Stdio; - -use anyhow::{Context, Result}; -use tempfile::tempdir; -use tokio::process::Command; +use anyhow::Result; use crate::config::{Environment, Installer, Machine, Testsuite}; -use super::img::QemuImage; -use super::install::install; -use super::layer::run_playbooks; -use super::vm::QemuVm; +use super::session::Session; pub async fn test( machine: &Machine, @@ -20,46 +13,20 @@ pub async fn test( installer: &Installer, testsuite: &Testsuite, ) -> Result<()> { - let install_disk = install(machine, environment, installer).await?; - let test_dir = tempdir().unwrap(); - let test_disk = test_dir.path().join("test.qcow2"); - let image = QemuImage::create(&install_disk, test_disk).await; - let vm = QemuVm::start(&image).await; - - run_playbooks(&testsuite.playbooks, &testsuite.inputs).await?; + let session = Session::up(machine, environment, installer).await?; + session + .run_playbooks(&testsuite.playbooks, &testsuite.inputs) + .await?; if testsuite.interactive { println!("Opening an SSH shell in the tmachine VM."); - let ssh_status = Command::new("sshpass") - .env("SSHPASS", "tmachine") - .args([ - "-e", - "ssh", - "-tt", - "-p", - "2222", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "LogLevel=ERROR", - "tmachine@127.0.0.1", - ]) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .await - .context("open SSH shell in tmachine VM")?; - - vm.shutdown().await; - vm.wait().await; + let ssh_status = session.shell().await; + session.down().await; + let ssh_status = ssh_status?; anyhow::ensure!(ssh_status.success(), "SSH shell exited with {ssh_status}"); return Ok(()); } - vm.shutdown().await; - vm.wait().await; + session.down().await; Ok(()) } diff --git a/tests/tmachine/src/qemu/vm.rs b/tests/tmachine/src/qemu/vm.rs index 1e035632e2..34751bb9a8 100644 --- a/tests/tmachine/src/qemu/vm.rs +++ b/tests/tmachine/src/qemu/vm.rs @@ -38,6 +38,11 @@ impl QemuVm { Self { child, runtime_dir } } + /// The exit status, if QEMU has already exited. + pub(super) fn try_wait(&mut self) -> Option { + self.child.try_wait().ok().flatten() + } + pub(super) async fn wait(mut self) { let status = self.child.wait().await.unwrap(); assert!(status.success()); @@ -117,8 +122,10 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .arg(format!("unix:{},server=on,wait=off", qmp_socket.display())); if let Some(firmware_vars) = firmware_vars { - let firmware_code = option_env!("TMACHINE_FIRMWARE_CODE") - .expect("TMACHINE_FIRMWARE_CODE must be set for ARM builds"); + let firmware_code = firmware_path( + "TMACHINE_FIRMWARE_CODE", + option_env!("TMACHINE_FIRMWARE_CODE"), + ); command .arg("-drive") .arg(format!( @@ -154,11 +161,23 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - command } +/// A firmware path from the runtime environment, falling back to the value +/// compiled in. The fallback lets library users built outside Nix (such as +/// the test runner) supply firmware at runtime. +fn firmware_path(name: &str, compiled: Option<&'static str>) -> String { + std::env::var(name) + .ok() + .or_else(|| compiled.map(str::to_string)) + .unwrap_or_else(|| panic!("{name} must be set for ARM builds")) +} + fn prepare_firmware(runtime_dir: &Path) -> Option { #[cfg(target_arch = "aarch64")] { - let template = option_env!("TMACHINE_FIRMWARE_VARS") - .expect("TMACHINE_FIRMWARE_VARS must be set for ARM builds"); + let template = firmware_path( + "TMACHINE_FIRMWARE_VARS", + option_env!("TMACHINE_FIRMWARE_VARS"), + ); let firmware_vars = runtime_dir.join("firmware-vars.fd"); fs::copy(template, &firmware_vars).unwrap(); fs::set_permissions(&firmware_vars, fs::Permissions::from_mode(0o600)).unwrap(); diff --git a/tests/waivers.yaml b/tests/waivers.yaml new file mode 100644 index 0000000000..77b614a193 --- /dev/null +++ b/tests/waivers.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Known bugs that skip a test in some environments. See TESTING.md. +# +# waivers: +# - test: t-xxxxxxxx # the test's stable ID +# environments: { runtime: podman-rootless } # omit for all environments +# issue: https://github.com/NVIDIA/OpenShell/issues/NNNN +# reason: what fails and why +waivers: []