diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index f60dedbe22..2d1b0200bd 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -141,6 +141,9 @@ jobs: shell: nix develop -c bash -euo pipefail {0} run: | cargo fmt --all -- --check + cargo fmt --manifest-path e2e/support/rust/Cargo.toml --all -- --check + cargo fmt --manifest-path e2e/suites/conformance/Cargo.toml --all -- --check + cargo fmt --manifest-path e2e/suites/drivers/Cargo.toml --all -- --check 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 @@ -196,6 +199,9 @@ jobs: shell: nix develop -c bash -euo pipefail {0} run: | cargo clippy --locked --workspace --all-targets -- -D warnings + cargo clippy --locked --manifest-path e2e/support/rust/Cargo.toml --workspace --all-targets -- -D warnings + cargo clippy --locked --manifest-path e2e/suites/conformance/Cargo.toml --workspace --all-targets -- -D warnings + cargo clippy --locked --manifest-path e2e/suites/drivers/Cargo.toml --workspace --all-targets -- -D warnings 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 @@ -235,6 +241,9 @@ jobs: OPENSHELL_TELEMETRY_ENABLED: "false" 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 e2e/support/rust/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path e2e/suites/conformance/Cargo.toml --package openshell-test-conformance-cli -E 'binary(policy_advisor) & test(draft_assertion::tests::)' + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path e2e/suites/drivers/Cargo.toml --package openshell-test-suite-podman -E 'test(::tests::)' cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml rust-build-modes: diff --git a/AGENTS.md b/AGENTS.md index 4ac21136a3..bbc2b31050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,6 @@ Do not rely on this file for a full inventory. The detailed public and contribut | Path | Components | Purpose | |------|-----------|---------| | `crates/openshell-cli/` | CLI binary | User-facing command-line interface | -| `crates/openshell-conformance/` | CLI conformance library | Reusable driver-agnostic scenarios and command runner | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Capability-free workload launcher, process identity, and seccomp-mediated I/O | | `crates/openshell-supervisor/` | Supervisor runtime | Gateway session, policy evaluation, credentials, and upstream networking | @@ -61,6 +60,9 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `python/openshell/` | Python SDK | Python bindings and CLI packaging | | `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types | | `proto/` | Protobuf definitions | gRPC service contracts | +| `e2e/suites/` | Behavioral E2E suites | Conformance scenarios, feature tests, and driver tests | +| `e2e/support/rust/` | Shared Rust test tooling | CLI runner, binary resolution, output parsing, and port utilities | +| `tests/` | Test provisioning | Target configuration, artifact construction, and Ansible setup | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | | `docs/` | Published docs | MDX pages, navigation, and content assets | | `fern/` | Docs site config | Fern site config, components, and theme assets | diff --git a/CI.md b/CI.md index a433754151..ad7a247584 100644 --- a/CI.md +++ b/CI.md @@ -522,6 +522,14 @@ merge. Do not add the informational Actionlint, Zizmor, Dependency Review, or CodeQL jobs to the required status list while they remain in observation mode. +## Conformance source checks + +Branch Rust checks format and lint the separate +`e2e/suites/conformance` workspace and shared tooling in `e2e/support/rust`. +The Rust test jobs run the shared tooling and select the pure policy assertion +tests in the conformance CLI package with the CI nextest profile, without a gateway. Gateway-backed CLI tests retain their driver E2E and installed-artifact +integration lanes; source unit-test jobs do not execute those entry points. + ## Nix download recovery Jobs that enter the development shell enable `prepare-shell: "true"` on diff --git a/Cargo.lock b/Cargo.lock index 08d4c2bbb5..54537bb774 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4295,17 +4295,6 @@ dependencies = [ "url", ] -[[package]] -name = "openshell-conformance" -version = "0.0.0" -dependencies = [ - "rand 0.9.4", - "serde", - "serde_json", - "tempfile", - "tokio", -] - [[package]] name = "openshell-core" version = "0.0.0" diff --git a/TESTING.md b/TESTING.md index 570d135f5b..ae1bc2f395 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,6 +8,22 @@ mise run e2e # End-to-end tests (starts a Docker-backed gateway) mise run ci # Everything: lint, compile checks, and tests ``` +## Test Families + +- Lint checks formatting, style, and static rules. +- Unit tests verify a component in isolation. +- Integration tests verify interactions between components. +- End-to-end tests exercise a configured OpenShell target through a client. +- Benchmarks measure performance or scale separately from behavioral correctness. + +End-to-end suites under `e2e/suites/` are grouped by intent: conformance verifies +required public behavior, feature suites need a named external service or special +gateway configuration, and driver suites verify driver or host integration. +The [conformance guide](e2e/suites/conformance/README.md) owns conformance +requirements, authoring, and execution guidance. Existing mixed suites remain +under `e2e/rust/` while their behavior is migrated. `tests/` supplies provisioning +and artifact construction independently of the suite layout. + ## Test Layout ```text @@ -15,7 +31,9 @@ crates/*/src/ # Inline #[cfg(test)] modules 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 +e2e/rust/ # Legacy Rust CLI E2E tests awaiting migration +e2e/suites/ # Conformance, feature, and driver E2E suites +tests/ # Target provisioning and artifact construction ``` ## Rust Tests @@ -210,10 +228,15 @@ When more than one test needs this behavior, put the transport in the shared Rust e2e harness and require callers to use it instead of duplicating DNS, HTTP `Host`, TLS SNI, and mTLS handling. +Shared Rust test tooling lives under `e2e/support/rust`, including the CLI runner, +binary resolution, output parsing, and port utilities. Conformance scenarios live +under `e2e/suites/conformance/cli/tests`. `mise run test:rust` includes the tooling +and scenario unit tests without requiring a gateway. + Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. -- CLI conformance (`tests/suites/conformance`) - portable Cargo tests for +- CLI conformance (`e2e/suites/conformance`) - portable Cargo tests for lifecycle, mechanistic drafts, file transfer, and the sandbox-local API, including agent-authored permission requests. Driver E2E runs the complete Cargo test package. The installed-artifact conformance suite runs the same @@ -251,7 +274,7 @@ cargo build --package openshell-cli OPENSHELL_BIN="$PWD/target/debug/openshell" \ cargo test \ --locked \ - --manifest-path tests/suites/conformance/Cargo.toml \ + --manifest-path e2e/suites/conformance/Cargo.toml \ --package openshell-test-conformance-cli \ --no-fail-fast \ -- \ @@ -313,7 +336,14 @@ The `e2e-podman` testsuite runs a nextest archive built with the corresponding Rust feature and preloads its Python workload image into the rootless Podman store. The separate `driver-podman` testsuite compares OpenShell and direct Podman user-namespace mappings for the default, `auto`, `keep-id`, and private -profiles. The E2E archive excludes binaries that still depend on wrapper-owned +profiles. Each profile is a named Rust test. The suite runs serially against +the shared gateway; a guest-side file lock also protects separate invocations. +The Rust fixture restores the original gateway configuration, restarts and +checks gateway health, and verifies sandbox cleanup after each test, including +assertion failures. A dirty marker stops later tests after interruption or failed +restoration; start a fresh tmachine invocation to recover. + +The E2E archive excludes binaries that still depend on wrapper-owned gateway controls, host fixtures, missing guest tools, or nondeterministic relay setup. The `driver-podman` suite replaces the removed `podman_userns` E2E binary. `tests/artifacts.nix` keeps the follow-up exclusions explicit and uses diff --git a/crates/openshell-conformance/Cargo.toml b/crates/openshell-conformance/Cargo.toml deleted file mode 100644 index fbe777a7aa..0000000000 --- a/crates/openshell-conformance/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-conformance" -description = "Reusable OpenShell CLI conformance scenarios and runner" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -rand.workspace = true -serde.workspace = true -serde_json.workspace = true -tempfile = "3" -tokio.workspace = true - -[lints] -workspace = true diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs deleted file mode 100644 index 21d3fe8af0..0000000000 --- a/crates/openshell-conformance/src/scenarios/file_transfer.rs +++ /dev/null @@ -1,688 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable CLI file-transfer conformance scenario. - -use std::fs; -use std::path::Path; -use std::process::Stdio; -use std::time::Duration; - -use tokio::process::Command; - -use crate::{CommandResult, OpenShellRunner, Scenario, ScenarioFuture}; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); -const TRANSFER_TIMEOUT: Duration = Duration::from_mins(5); -const LARGE_FILE_SIZE: usize = 512 * 1024; - -/// Certify basic file and directory upload and download behavior. -pub const FILE_TRANSFER_ROUND_TRIP_SCENARIO: Scenario = Scenario { - name: "file-transfer/round-trip", - run: run_round_trip, -}; - -/// Certify Git-aware upload filtering and explicit unfiltered uploads. -pub const FILE_TRANSFER_GIT_FILTERING_SCENARIO: Scenario = Scenario { - name: "file-transfer/git-filtering", - run: run_git_filtering, -}; - -/// Certify file-transfer workspace boundary and filename safety behavior. -pub const FILE_TRANSFER_PATH_SAFETY_SCENARIO: Scenario = Scenario { - name: "file-transfer/path-safety", - run: run_path_safety, -}; - -fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?; - round_trip(runner, &sandbox_name, &remote_root, local.path()).await?; - download_file(runner, &sandbox_name, &remote_root, local.path()).await?; - download_directory(runner, &sandbox_name, &remote_root, local.path()).await?; - delete_sandbox(runner, &sandbox_name).await - }) -} - -fn run_git_filtering(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "git-filtering").await?; - gitignore_filtering(runner, &sandbox_name, &remote_root, local.path()).await?; - single_file_from_git_repo(runner, &sandbox_name, &remote_root, local.path()).await?; - gitignored_directory_requires_override(runner, &sandbox_name, &remote_root, local.path()) - .await?; - delete_sandbox(runner, &sandbox_name).await - }) -} - -fn run_path_safety(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "path-safety").await?; - reject_workspace_escape(runner, &sandbox_name, &remote_root, local.path()).await?; - download_dash_leading_name(runner, &sandbox_name, &remote_root, local.path()).await?; - delete_sandbox(runner, &sandbox_name).await - }) -} - -async fn prepare_sandbox( - runner: &mut OpenShellRunner, - group: &str, -) -> Result<(String, String, tempfile::TempDir), String> { - let suffix = match group { - "round-trip" => "fr", - "git-filtering" => "fg", - "path-safety" => "fs", - _ => return Err(format!("unknown file-transfer group {group:?}")), - }; - let sandbox_name = format!("ct-{}-{suffix}", runner.id()); - let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", runner.id()); - let local = - tempfile::tempdir().map_err(|error| format!("create temporary directory: {error}"))?; - - runner.track_sandbox(&sandbox_name); - let create = runner - .step(format!("{group}/create")) - .description(format!("sandbox '{sandbox_name}' is created")) - .with_timeout(CREATE_TIMEOUT) - .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - - exec( - runner, - &sandbox_name, - &format!("{group}/prepare"), - &format!("mkdir -p '{remote_root}'"), - ) - .await?; - - Ok((sandbox_name, remote_root, local)) -} - -async fn delete_sandbox(runner: &mut OpenShellRunner, sandbox_name: &str) -> Result<(), String> { - let delete = runner - .step("delete") - .description(format!("sandbox '{sandbox_name}' is deleted")) - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", "delete", sandbox_name]) - .await - .map_err(|error| error.to_string())?; - delete.require_success()?; - runner.forget_sandbox(sandbox_name); - Ok(()) -} - -async fn round_trip( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let source = local_root.join("roundtrip-upload"); - fs::create_dir_all(source.join("subdir")).map_err(fs_error("create round-trip source"))?; - fs::write(source.join("greeting.txt"), "hello-from-local") - .map_err(fs_error("write greeting.txt"))?; - fs::write(source.join("subdir/nested.txt"), "nested-content") - .map_err(fs_error("write nested.txt"))?; - - let large = (0u8..=250) - .cycle() - .take(LARGE_FILE_SIZE) - .collect::>(); - fs::write(source.join("large.bin"), &large).map_err(fs_error("write large.bin"))?; - - let remote = format!("{remote_root}/roundtrip"); - let result = - upload_result(runner, sandbox, "roundtrip/upload", &source, &remote, false).await?; - result.require_success()?; - if !result.stderr().contains("outside a Git work tree") - || !result.stderr().contains(".gitignore rules are not applied") - { - return Err( - result.failure_diagnostic("upload outside Git warns that filtering is disabled") - ); - } - - let destination = local_root.join("roundtrip-download"); - fs::create_dir(&destination).map_err(fs_error("create round-trip destination"))?; - let remote_source = format!("{remote}/roundtrip-upload"); - download( - runner, - sandbox, - "roundtrip/download", - &remote_source, - &destination, - ) - .await?; - - require_text( - &destination.join("greeting.txt"), - "hello-from-local", - "round-trip greeting", - )?; - require_text( - &destination.join("subdir/nested.txt"), - "nested-content", - "round-trip nested file", - )?; - let actual = fs::read(destination.join("large.bin")).map_err(fs_error("read large.bin"))?; - if actual != large { - return Err(format!( - "large file changed during round trip: expected {} bytes, received {} bytes", - large.len(), - actual.len() - )); - } - - let single = local_root.join("single.txt"); - fs::write(&single, "single-file-payload").map_err(fs_error("write single.txt"))?; - let remote_single = format!("{remote_root}/single.txt"); - upload( - runner, - sandbox, - "single/upload", - &single, - &remote_single, - false, - ) - .await?; - let single_destination = local_root.join("single-download"); - fs::create_dir(&single_destination).map_err(fs_error("create single-file destination"))?; - download( - runner, - sandbox, - "single/download", - &remote_single, - &single_destination, - ) - .await?; - require_text( - &single_destination.join("single.txt"), - "single-file-payload", - "single-file round trip", - ) -} - -async fn gitignore_filtering( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let repository = local_root.join("filter-repo"); - fs::create_dir(&repository).map_err(fs_error("create filter repository"))?; - git_init(&repository).await?; - fs::write(repository.join(".gitignore"), "*.log\nbuild/\n") - .map_err(fs_error("write filter .gitignore"))?; - fs::write(repository.join("tracked.txt"), "i-am-tracked") - .map_err(fs_error("write tracked.txt"))?; - fs::write(repository.join("ignored.log"), "i-should-be-filtered") - .map_err(fs_error("write ignored.log"))?; - fs::create_dir(repository.join("build")).map_err(fs_error("create ignored build directory"))?; - fs::write(repository.join("build/output.bin"), "build-artifact") - .map_err(fs_error("write ignored build artifact"))?; - git(&repository, &["add", "."]).await?; - - let remote = format!("{remote_root}/filtered"); - upload( - runner, - sandbox, - "gitignore/upload", - &repository, - &remote, - false, - ) - .await?; - - let destination = local_root.join("filter-download"); - fs::create_dir(&destination).map_err(fs_error("create filter destination"))?; - download(runner, sandbox, "gitignore/download", &remote, &destination).await?; - let uploaded = destination.join("filter-repo"); - require_text( - &uploaded.join("tracked.txt"), - "i-am-tracked", - "Git-aware tracked file", - )?; - require_exists(&uploaded.join(".gitignore"), "uploaded .gitignore")?; - require_absent(&uploaded.join("ignored.log"), "Git-ignored file")?; - require_absent(&uploaded.join("build"), "Git-ignored directory") -} - -async fn single_file_from_git_repo( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let repository = local_root.join("single-repo"); - fs::create_dir_all(repository.join("nested")) - .map_err(fs_error("create single-file repository"))?; - git_init(&repository).await?; - fs::write(repository.join(".gitignore"), "*.log\n") - .map_err(fs_error("write single-file .gitignore"))?; - fs::write( - repository.join("nested/config.txt"), - "single-file-from-repo", - ) - .map_err(fs_error("write repository config.txt"))?; - fs::write(repository.join("tracked.txt"), "should-not-upload") - .map_err(fs_error("write unrelated tracked.txt"))?; - fs::write(repository.join("ignored.log"), "ignored") - .map_err(fs_error("write repository ignored.log"))?; - - let remote = format!("{remote_root}/single-from-repo"); - upload( - runner, - sandbox, - "single-from-repo/upload", - &repository.join("nested/config.txt"), - &remote, - false, - ) - .await?; - let destination = local_root.join("single-repo-download"); - fs::create_dir(&destination).map_err(fs_error("create single-repo destination"))?; - download( - runner, - sandbox, - "single-from-repo/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("config.txt"), - "single-file-from-repo", - "single file selected from repository", - )?; - require_absent( - &destination.join("tracked.txt"), - "unselected repository file", - )?; - require_absent(&destination.join("ignored.log"), "ignored repository file") -} - -async fn download_file( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote = format!("{remote_root}/download-file.txt"); - exec( - runner, - sandbox, - "download-file/seed", - &format!("printf greeting-payload > '{remote}'"), - ) - .await?; - let destination = local_root.join("download-file"); - fs::create_dir(&destination).map_err(fs_error("create file-download destination"))?; - download( - runner, - sandbox, - "download-file/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("download-file.txt"), - "greeting-payload", - "downloaded file", - ) -} - -async fn download_directory( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote = format!("{remote_root}/tree"); - exec( - runner, - sandbox, - "download-directory/seed", - &format!( - "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && printf nested > '{remote}/sub/child.txt'" - ), - ) - .await?; - let destination = local_root.join("download-directory"); - fs::create_dir(&destination).map_err(fs_error("create directory-download destination"))?; - download( - runner, - sandbox, - "download-directory/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("root.txt"), - "top-level", - "downloaded directory root file", - )?; - require_text( - &destination.join("sub/child.txt"), - "nested", - "downloaded directory nested file", - ) -} - -async fn reject_workspace_escape( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let etc_link = format!("{remote_root}/etc-link"); - let passwd_link = format!("{remote_root}/passwd-link"); - exec( - runner, - sandbox, - "workspace-escape/seed", - &format!("ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'"), - ) - .await?; - let destination = local_root.join("workspace-escape"); - fs::create_dir(&destination).map_err(fs_error("create workspace-escape destination"))?; - - for (step, source) in [ - ("directory-link", etc_link.clone()), - ("file-link", passwd_link), - ("linked-component", format!("{etc_link}/passwd")), - ] { - let result = download_result( - runner, - sandbox, - &format!("workspace-escape/{step}"), - &source, - &destination, - ) - .await?; - if result.success() { - return Err(format!( - "download unexpectedly accepted sandbox path {source:?} that resolves outside the workspace" - )); - } - let diagnostic = format!("{}\n{}", result.stdout(), result.stderr()); - if !diagnostic.contains("resolves to") - || !diagnostic.contains("outside the") - || !diagnostic.contains("sandbox workspace") - { - return Err(result.failure_diagnostic( - "download is rejected because the resolved source is outside the sandbox workspace", - )); - } - } - require_absent(&destination.join("passwd"), "escaped passwd file")?; - require_absent(&destination.join("etc-link"), "escaped /etc directory") -} - -async fn download_dash_leading_name( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote = format!("{remote_root}/--checkpoint-action=evil"); - exec( - runner, - sandbox, - "dash-leading/seed", - &format!("printf dash-payload > '{remote}'"), - ) - .await?; - let destination = local_root.join("dash-leading"); - fs::create_dir(&destination).map_err(fs_error("create dash-leading destination"))?; - download( - runner, - sandbox, - "dash-leading/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("--checkpoint-action=evil"), - "dash-payload", - "dash-leading file", - ) -} - -async fn gitignored_directory_requires_override( - runner: &OpenShellRunner, - sandbox: &str, - remote_root: &str, - local_root: &Path, -) -> Result<(), String> { - let remote_seed = format!("{remote_root}/runs/test.json"); - exec( - runner, - sandbox, - "gitignored-override/seed", - &format!("mkdir -p '{remote_root}/runs' && printf downloaded-payload > '{remote_seed}'"), - ) - .await?; - - let repository = local_root.join("override-repo"); - fs::create_dir(&repository).map_err(fs_error("create override repository"))?; - git_init(&repository).await?; - fs::write(repository.join(".gitignore"), "runs/\n") - .map_err(fs_error("write override .gitignore"))?; - let runs = repository.join("runs"); - fs::create_dir(&runs).map_err(fs_error("create ignored runs directory"))?; - download( - runner, - sandbox, - "gitignored-override/download-seed", - &remote_seed, - &runs, - ) - .await?; - require_exists(&runs.join("test.json"), "downloaded ignored file")?; - - let remote = format!("{remote_root}/reuploaded"); - let rejected = upload_result( - runner, - sandbox, - "gitignored-override/reject-upload", - &runs, - &remote, - false, - ) - .await?; - let output = format!("{}\n{}", rejected.stdout(), rejected.stderr()); - if rejected.success() - || !output.contains("filtering selected no files") - || !output.contains("--no-git-ignore") - { - return Err(rejected.failure_diagnostic( - "upload rejects an empty Git selection and explains the explicit override", - )); - } - exec( - runner, - sandbox, - "gitignored-override/no-transfer", - &format!("test ! -e '{remote}'"), - ) - .await?; - upload( - runner, - sandbox, - "gitignored-override/upload", - &runs, - &remote, - true, - ) - .await?; - - let destination = local_root.join("override-download"); - fs::create_dir(&destination).map_err(fs_error("create override destination"))?; - download( - runner, - sandbox, - "gitignored-override/download", - &remote, - &destination, - ) - .await?; - require_text( - &destination.join("runs/test.json"), - "downloaded-payload", - "re-uploaded Git-ignored file", - ) -} - -async fn upload( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &Path, - destination: &str, - no_git_ignore: bool, -) -> Result<(), String> { - let result = upload_result(runner, sandbox, step, source, destination, no_git_ignore).await?; - result.require_success() -} - -async fn upload_result( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &Path, - destination: &str, - no_git_ignore: bool, -) -> Result { - let source = source - .to_str() - .ok_or_else(|| format!("local upload path is not UTF-8: {}", source.display()))?; - let mut args = vec!["sandbox", "upload", sandbox, source, destination]; - if no_git_ignore { - args.push("--no-git-ignore"); - } - runner - .step(step) - .description(format!("upload {source:?} to {destination:?}")) - .with_timeout(TRANSFER_TIMEOUT) - .run(&args) - .await - .map_err(|error| error.to_string()) -} - -async fn download( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &str, - destination: &Path, -) -> Result<(), String> { - let result = download_result(runner, sandbox, step, source, destination).await?; - result.require_success() -} - -async fn download_result( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - source: &str, - destination: &Path, -) -> Result { - let destination = destination.to_str().ok_or_else(|| { - format!( - "local download path is not UTF-8: {}", - destination.display() - ) - })?; - runner - .step(step) - .description(format!( - "download {source:?} to {destination:?} has the expected disposition" - )) - .with_timeout(TRANSFER_TIMEOUT) - .run(&["sandbox", "download", sandbox, source, destination]) - .await - .map_err(|error| error.to_string()) -} - -async fn exec( - runner: &OpenShellRunner, - sandbox: &str, - step: &str, - script: &str, -) -> Result<(), String> { - let result = runner - .step(step) - .description(format!("sandbox fixture setup for {step} succeeds")) - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", script, - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success() -} - -async fn git_init(repository: &Path) -> Result<(), String> { - git(repository, &["init", "--quiet"]).await -} - -async fn git(repository: &Path, args: &[&str]) -> Result<(), String> { - let output = Command::new("git") - .args(args) - .current_dir(repository) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|error| format!("failed to run git in {}: {error}", repository.display()))?; - if output.status.success() { - return Ok(()); - } - Err(format!( - "git {} failed in {} with status {}\nstdout:\n{}\nstderr:\n{}", - args.join(" "), - repository.display(), - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - )) -} - -fn require_text(path: &Path, expected: &str, label: &str) -> Result<(), String> { - let actual = fs::read_to_string(path) - .map_err(|error| format!("read {label} at {}: {error}", path.display()))?; - if actual == expected { - Ok(()) - } else { - Err(format!( - "{label} content mismatch at {}: expected {expected:?}, received {actual:?}", - path.display() - )) - } -} - -fn require_exists(path: &Path, label: &str) -> Result<(), String> { - if path.exists() { - Ok(()) - } else { - Err(format!("{label} does not exist at {}", path.display())) - } -} - -fn require_absent(path: &Path, label: &str) -> Result<(), String> { - if path.exists() { - Err(format!("{label} unexpectedly exists at {}", path.display())) - } else { - Ok(()) - } -} - -fn fs_error(context: &'static str) -> impl FnOnce(std::io::Error) -> String { - move |error| format!("{context}: {error}") -} diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs deleted file mode 100644 index 72a90925f0..0000000000 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable conformance scenarios exercised by the Cargo test suite. - -mod file_transfer; -mod policy_behavior; -mod sandbox_lifecycle; -mod smoke; - -pub use file_transfer::{ - FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, -}; -pub use policy_behavior::{ - MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, -}; -pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_SCENARIO; -pub use smoke::SMOKE_SCENARIO; diff --git a/crates/openshell-conformance/src/scenarios/policy_behavior.rs b/crates/openshell-conformance/src/scenarios/policy_behavior.rs deleted file mode 100644 index 386b8e0f0b..0000000000 --- a/crates/openshell-conformance/src/scenarios/policy_behavior.rs +++ /dev/null @@ -1,579 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable policy-local and mechanistic proposal checks. - -use std::io::Write as _; -use std::time::{Duration, Instant}; - -use serde::Deserialize; -use serde_json::Value; -use tempfile::NamedTempFile; -use tokio::time::sleep; - -use crate::{OpenShellRunner, Scenario, ScenarioFuture}; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const COMMAND_TIMEOUT: Duration = Duration::from_secs(45); -const READY_TIMEOUT: Duration = Duration::from_mins(4); -const POLL_INTERVAL: Duration = Duration::from_secs(2); -const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(90); - -#[derive(Deserialize)] -struct SandboxState { - name: String, - phase: String, -} - -pub const POLICY_LOCAL_SCENARIO: Scenario = Scenario { - name: "policy-local", - run: run_policy_local, -}; - -pub const MECHANISTIC_PROPOSAL_SCENARIO: Scenario = Scenario { - name: "mechanistic-proposal", - run: run_mechanistic_proposal, -}; - -pub const NEW_HOSTNAME_PROPOSAL_SCENARIO: Scenario = Scenario { - name: "new-hostname-proposal", - run: run_new_hostname_proposal, -}; - -const EMPTY_NETWORK_POLICY: &[u8] = br"version: 1 -filesystem_policy: - include_workdir: true - read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] -landlock: { compatibility: best_effort } -network_policies: {} -"; - -fn run_policy_local(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let name = format!("ct-{}-pl", runner.id()); - create_sandbox(runner, &name, None).await?; - enable_proposals(runner, &name).await?; - let binary = sandbox_bash_path(runner, &name).await?; - - let started = Instant::now(); - let readiness_path = format!("/v1/proposals/ct-{}-readiness", runner.id()); - loop { - match request_policy_local(runner, &name, "/v1/policy/current").await { - Ok(response) - if response["format"] == "yaml" - && response["policy_yaml"] - .as_str() - .is_some_and(|yaml| yaml.contains("version: 1")) => - { - // The current-policy route is local; proposal submission also - // needs the supervisor's workspace and gateway lookup session. - match request_policy_local_http(runner, &name, "GET", &readiness_path, "", 404) - .await - { - Ok(lookup) if lookup["error"] == "chunk_not_found" => break, - Ok(lookup) => { - return Err(format!( - "policy.local proposal lookup returned an invalid readiness response: {lookup}" - )); - } - Err(error) if started.elapsed() >= READY_TIMEOUT => return Err(error), - Err(_) => {} - } - } - Ok(response) => { - return Err(format!( - "policy.local returned an invalid current policy: {response}" - )); - } - Err(error) => { - if started.elapsed() >= READY_TIMEOUT { - return Err(error); - } - } - } - sleep(POLL_INTERVAL).await; - } - let denials = request_policy_local(runner, &name, "/v1/denials?last=1").await?; - if !denials["denials"].is_array() || !denials["log_available"].is_boolean() { - return Err(format!( - "policy.local returned an invalid denials response: {denials}" - )); - } - - let rule_name = format!("conformance_local_{}", runner.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 = - request_policy_local_http(runner, &name, "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(|| format!("policy.local did not accept one proposal: {submitted}"))?; - if submitted["status"] != "submitted" - || submitted["accepted_chunks"] != 1 - || submitted["rejected_chunks"] != 0 - { - return Err(format!("policy.local did not submit one rule: {submitted}")); - } - - let state = - request_policy_local(runner, &name, &format!("/v1/proposals/{chunk_id}")).await?; - if state["chunk_id"] != chunk_id - || state["rule_name"] != rule_name - || state["binary"] != binary - || !matches!(state["status"].as_str(), Some("pending" | "approved")) - { - return Err(format!("policy.local returned the wrong proposal: {state}")); - } - - let review = runner - .step("reviewer-inbox") - .description("the requested rule is visible to the reviewer") - .with_timeout(COMMAND_TIMEOUT) - .run(&["rule", "get", &name]) - .await - .map_err(|error| error.to_string())?; - review.require_success()?; - if !review.stdout().contains(&format!("Chunk: {chunk_id}")) - || !review.stdout().contains(&format!("Rule: {rule_name}")) - { - return Err(review.failure_diagnostic("the submitted rule is in the reviewer inbox")); - } - Ok(()) - }) -} - -async fn sandbox_bash_path(runner: &OpenShellRunner, name: &str) -> Result { - let result = runner - .step("bash-binary") - .description("the sandbox's Bash executable has a canonical path") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - name, - "--no-tty", - "--", - "bash", - "-c", - "printf '%s\\n' \"$(readlink -f /proc/$$/exe)\"", - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - let binary = result.stdout().trim(); - if !binary.starts_with('/') - || binary.contains('\n') - || binary.rsplit('/').next() != Some("bash") - { - return Err(result.failure_diagnostic("one absolute Bash executable path ending in /bash")); - } - Ok(binary.to_string()) -} - -async fn enable_proposals(runner: &OpenShellRunner, name: &str) -> Result<(), String> { - let set = runner - .step("enable-policy-advisor") - .description("sandbox-scoped policy advisor setting is enabled") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "settings", - "set", - name, - "--key", - "agent_policy_proposals_enabled", - "--value", - "true", - ]) - .await - .map_err(|error| error.to_string())?; - set.require_success()?; - - let settings = runner - .step("effective-settings") - .description("policy advisor setting is effectively enabled") - .with_timeout(COMMAND_TIMEOUT) - .run(&["settings", "get", name, "--json"]) - .await - .map_err(|error| error.to_string())?; - settings.require_success()?; - let value: Value = settings.json().map_err(|error| error.to_string())?; - if value["settings"]["agent_policy_proposals_enabled"]["value"] != "true" { - return Err(settings.failure_diagnostic( - "effective agent_policy_proposals_enabled is true; check for a global override", - )); - } - Ok(()) -} - -async fn request_policy_local( - runner: &OpenShellRunner, - sandbox: &str, - path: &str, -) -> Result { - request_policy_local_http(runner, sandbox, "GET", path, "", 200).await -} - -async fn request_policy_local_http( - runner: &OpenShellRunner, - sandbox: &str, - method: &str, - path: &str, - body: &str, - expected_status: u16, -) -> Result { - let script = "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 result = runner - .step(format!("policy-local-{method}{path}")) - .description(format!( - "{method} http://policy.local{path} succeeds from the sandbox" - )) - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - sandbox, - "--no-tty", - "--", - "bash", - "-c", - script, - "policy-local-http", - method, - path, - body, - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - let (headers, body) = result.stdout().split_once("\r\n\r\n").ok_or_else(|| { - result.failure_diagnostic("a complete HTTP response with headers and JSON body") - })?; - if !headers.starts_with(&format!("HTTP/1.1 {expected_status} ")) - || !headers.lines().any(|line| { - line.to_ascii_lowercase() - .starts_with("content-type: application/json") - }) - { - return Err( - result.failure_diagnostic(&format!("HTTP {expected_status} with JSON Content-Type")) - ); - } - serde_json::from_str(body) - .map_err(|error| result.failure_diagnostic(&format!("valid JSON body: {error}"))) -} - -fn run_mechanistic_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; - policy - .write_all(EMPTY_NETWORK_POLICY) - .map_err(|error| error.to_string())?; - let policy_path = policy - .path() - .to_str() - .ok_or("temporary policy path is not UTF-8")?; - let name = format!("ct-{}-mp", runner.id()); - create_sandbox(runner, &name, Some(policy_path)).await?; - enable_proposals(runner, &name).await?; - - let effective = runner - .step("effective-policy") - .description("sandbox has no network allow rules before the probe") - .with_timeout(COMMAND_TIMEOUT) - .run(&["policy", "get", &name, "--full", "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - effective.require_success()?; - let value: Value = effective.json().map_err(|error| error.to_string())?; - let network_rules = &value["policy"]["network_policies"]; - if !network_rules.is_null() - && !network_rules - .as_object() - .is_some_and(serde_json::Map::is_empty) - { - return Err(effective.failure_diagnostic("effective network_policies is empty")); - } - - let probe = runner - .step("denied-tcp-open") - .description("one Bash TCP open to 1.1.1.1:443 is denied by policy") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &name, - "--no-tty", - "--", - "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 - .map_err(|error| error.to_string())?; - probe.require_success()?; - let binary = probe - .stdout() - .lines() - .find_map(|line| line.strip_prefix("BINARY=")) - .filter(|binary| binary.starts_with('/') && binary.rsplit('/').next() == Some("bash")) - .ok_or_else(|| probe.failure_diagnostic("canonical Bash executable path is reported"))? - .to_string(); - if !probe.stdout().lines().any(|line| line == "DENIED") { - return Err(probe.failure_diagnostic("TCP open is denied before any upstream dial")); - } - - await_mechanistic_draft( - runner, - &name, - &ExpectedDraft { - rule: "allow_1_1_1_1_443", - endpoint: "1.1.1.1:443", - binary: &binary, - }, - probe.stderr(), - ) - .await - }) -} - -fn run_new_hostname_proposal(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; - policy - .write_all(EMPTY_NETWORK_POLICY) - .map_err(|error| error.to_string())?; - let policy_path = policy - .path() - .to_str() - .ok_or("temporary policy path is not UTF-8")?; - let name = format!("ct-{}-nh", runner.id()); - create_sandbox(runner, &name, Some(policy_path)).await?; - let binary = sandbox_bash_path(runner, &name).await?; - - let probe = runner - .step("denied-new-hostname") - .description("Bash cannot connect to pypi.org:80 before approval") - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &name, - "--no-tty", - "--", - "bash", - "-c", - "if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", - ]) - .await - .map_err(|error| error.to_string())?; - probe.require_success()?; - if !probe.stdout().lines().any(|line| line == "DENIED") { - return Err(probe.failure_diagnostic("new hostname stays denied before approval")); - } - - await_mechanistic_draft( - runner, - &name, - &ExpectedDraft { - rule: "allow_pypi_org_80", - endpoint: "pypi.org:80", - binary: &binary, - }, - probe.stderr(), - ) - .await - }) -} - -/// The single L4 mechanistic draft expected for one denied endpoint. -struct ExpectedDraft<'a> { - rule: &'a str, - endpoint: &'a str, - binary: &'a str, -} - -/// Poll the reviewer inbox until a draft appears, tolerating transient read -/// failures, then require that it is the single expected draft. -async fn await_mechanistic_draft( - runner: &OpenShellRunner, - sandbox: &str, - expected: &ExpectedDraft<'_>, - probe_stderr: &str, -) -> Result<(), String> { - let started = Instant::now(); - loop { - let draft = runner - .step("mechanistic-draft") - .description("a single scoped mechanistic draft appears") - .with_timeout(COMMAND_TIMEOUT) - .run(&["rule", "get", sandbox]) - .await - .map_err(|error| error.to_string())?; - if !draft.success() { - if started.elapsed() >= PROPOSAL_TIMEOUT { - return Err(draft.failure_diagnostic("the reviewer inbox is readable")); - } - sleep(POLL_INTERVAL).await; - continue; - } - if !draft.stdout().contains("Chunk:") { - if started.elapsed() >= PROPOSAL_TIMEOUT { - return Err(draft.failure_diagnostic(&format!( - "one mechanistic draft for {} and {}; probe stderr:\n{probe_stderr}", - expected.endpoint, expected.binary - ))); - } - sleep(POLL_INTERVAL).await; - continue; - } - return assert_mechanistic_draft(draft.stdout(), expected) - .map_err(|error| draft.failure_diagnostic(&error)); - } -} - -fn assert_mechanistic_draft(output: &str, expected: &ExpectedDraft<'_>) -> Result<(), String> { - let fields = output.lines().map(str::trim).collect::>(); - let field = |name: &str| { - fields - .iter() - .find_map(|line| line.strip_prefix(name).map(str::trim)) - }; - let endpoints = format!("{} [L4]", expected.endpoint); - if fields - .iter() - .filter(|line| line.starts_with("Chunk:")) - .count() - != 1 - || !matches!(field("Status:"), Some("pending" | "approved")) - || field("Rule:") != Some(expected.rule) - || field("Binary:") != Some(expected.binary) - || field("Binaries:") != Some(expected.binary) - || field("Endpoints:") != Some(endpoints.as_str()) - || !field("Rationale:").is_some_and(|value| value.contains(expected.endpoint)) - { - return Err(format!( - "expected one pending or approved L4 mechanistic draft scoped to {} and {}", - expected.binary, expected.endpoint - )); - } - Ok(()) -} - -async fn create_sandbox( - runner: &mut OpenShellRunner, - name: &str, - policy_path: Option<&str>, -) -> Result<(), String> { - runner.track_sandbox(name); - let mut args = vec![ - "sandbox", - "create", - "--name", - name, - "--detach", - "--no-tty", - "--no-auto-providers", - ]; - if let Some(path) = policy_path { - args.extend(["--policy", path]); - } - args.extend(["--", "sh", "-c", "exec sleep infinity"]); - let create = runner - .step("create") - .description(format!("sandbox '{name}' is created")) - .with_timeout(CREATE_TIMEOUT) - .run(&args) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - - let started = Instant::now(); - loop { - let get = runner - .step("ready") - .description(format!("sandbox '{name}' reaches Ready")) - .with_timeout(COMMAND_TIMEOUT) - .run(&["sandbox", "get", name, "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - if get.success() { - let state: SandboxState = get.json().map_err(|error| error.to_string())?; - if state.name != name { - return Err(get.failure_diagnostic("sandbox get returns the created name")); - } - if state.phase == "Ready" { - return Ok(()); - } - if state.phase == "Failed" { - return Err(get.failure_diagnostic("sandbox reaches Ready instead of Failed")); - } - } - if started.elapsed() >= READY_TIMEOUT { - return Err(get.failure_diagnostic("sandbox reaches Ready before timeout")); - } - sleep(POLL_INTERVAL).await; - } -} - -#[cfg(test)] -mod tests { - use super::{ExpectedDraft, assert_mechanistic_draft}; - - const EXPECTED: ExpectedDraft<'static> = ExpectedDraft { - rule: "allow_pypi_org_80", - endpoint: "pypi.org:80", - binary: "/usr/bin/bash", - }; - - fn draft(binary: &str) -> String { - format!( - "Chunk: id\nStatus: pending\nRule: allow_pypi_org_80\nBinary: {binary}\nRationale: Allow {binary} to connect to pypi.org:80 (HTTP).\nEndpoints: pypi.org:80 [L4]\nBinaries: {binary}\n" - ) - } - - #[test] - fn draft_assertion_accepts_a_hostname_scoped_draft() { - assert!(assert_mechanistic_draft(&draft("/usr/bin/bash"), &EXPECTED).is_ok()); - } - - #[test] - fn draft_assertion_rejects_unrelated_binary() { - assert!(assert_mechanistic_draft(&draft("/usr/bin/sh"), &EXPECTED).is_err()); - } - - #[test] - fn draft_assertion_rejects_fields_spread_across_drafts() { - let drafts = format!( - "{}Chunk: other\nStatus: pending\nRule: allow_1_1_1_1_443\n", - draft("/usr/bin/bash") - ); - assert!(assert_mechanistic_draft(&drafts, &EXPECTED).is_err()); - } -} diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs deleted file mode 100644 index 0f627b7fa2..0000000000 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Portable phase-1 CLI conformance scenario. - -use std::time::{Duration, Instant}; - -use crate::{OpenShellRunner, STATUS_TIMEOUT, Scenario, ScenarioFuture}; -use serde::Deserialize; -use tokio::time::sleep; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const LIST_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); -const LIST_PAGE_SIZE: u32 = 1_000; -const EXEC_TIMEOUT: Duration = Duration::from_mins(2); -const DELETE_TIMEOUT: Duration = Duration::from_mins(2); -const DELETE_POLL_INTERVAL: Duration = Duration::from_secs(1); - -#[derive(Debug, Deserialize)] -struct SandboxListEntry { - name: String, - phase: String, -} - -#[derive(Debug, Deserialize)] -struct SandboxListPage { - sandboxes: Vec, - next_page_token: String, -} - -/// Certify status -> create -> list Ready -> exec -> delete -> list empty. -pub const SMOKE_SCENARIO: Scenario = Scenario { - name: "smoke", - run: run_smoke, -}; - -fn run_smoke(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { run_smoke_inner(runner).await }) -} - -async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { - let status = runner - .step("status") - .description("openshell status succeeds") - .with_timeout(STATUS_TIMEOUT) - .run(&["status"]) - .await - .map_err(|error| error.to_string())?; - status.require_success()?; - - let sandbox_name = format!("ct-{}-01", runner.id()); - runner.track_sandbox(&sandbox_name); - let create = runner - .step("create") - .description("sandbox creation succeeds") - .with_timeout(CREATE_TIMEOUT) - .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) - .await - .map_err(|error| error.to_string())?; - create.require_success()?; - - let get = runner - .step("get-ready") - .description(format!("sandbox '{sandbox_name}' can be retrieved")) - .with_timeout(LIST_ATTEMPT_TIMEOUT) - .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) - .await - .map_err(|error| error.to_string())?; - get.require_success()?; - - let sandbox = get - .json::() - .map_err(|error| error.to_string())?; - if sandbox.name != sandbox_name { - return Err(format!( - "sandbox get returned {:?}; expected sandbox '{sandbox_name}'", - sandbox.name - )); - } - if sandbox.phase != "Ready" { - return Err(format!( - "sandbox '{sandbox_name}' is in phase {:?}; expected Ready", - sandbox.phase - )); - } - - check_sandbox_listed(runner, &sandbox_name).await?; - - let marker = format!("openshell-conformance-{}", runner.id()); - let exec = runner - .step("exec") - .description("sandbox exec exits successfully") - .with_timeout(EXEC_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &sandbox_name, - "--no-tty", - "--", - "echo", - &marker, - ]) - .await - .map_err(|error| error.to_string())?; - exec.require_success()?; - let expected_stdout = format!("{marker}\n"); - if exec.stdout() != expected_stdout { - return Err(exec.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))); - } - - let delete = runner - .step("delete") - .description("sandbox deletion succeeds") - .with_timeout(DELETE_TIMEOUT) - .run(&["sandbox", "delete", &sandbox_name]) - .await - .map_err(|error| error.to_string())?; - delete.require_success()?; - - check_empty_list(runner, &sandbox_name).await?; - runner.forget_sandbox(&sandbox_name); - Ok(()) -} - -async fn check_sandbox_listed(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { - if find_sandbox(runner, sandbox_name, "list-visible") - .await? - .is_some() - { - return Ok(()); - } - Err(format!( - "sandbox '{sandbox_name}' does not appear in sandbox list" - )) -} - -async fn check_empty_list(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { - let started = Instant::now(); - - loop { - match find_sandbox(runner, sandbox_name, "list-empty/query").await? { - None => return Ok(()), - Some(sandbox) if started.elapsed() >= DELETE_TIMEOUT => { - return Err(format!( - "sandbox '{sandbox_name}' remains listed in phase {:?} after {DELETE_TIMEOUT:.1?}", - sandbox.phase - )); - } - Some(_) => sleep(DELETE_POLL_INTERVAL).await, - } - } -} - -async fn find_sandbox( - runner: &OpenShellRunner, - sandbox_name: &str, - step: &str, -) -> Result, String> { - let mut page_token = String::new(); - let mut page = 0u32; - - loop { - let page_size = LIST_PAGE_SIZE.to_string(); - let result = runner - .step(format!("{step}/{page}")) - .description(format!("sandbox list page {page} succeeds")) - .with_timeout(LIST_ATTEMPT_TIMEOUT) - .run(&[ - "sandbox", - "list", - "--page-size", - &page_size, - "--page-token", - &page_token, - "--output", - "json", - ]) - .await - .map_err(|error| error.to_string())?; - result.require_success()?; - - let response = result - .json::() - .map_err(|error| error.to_string())?; - if let Some(sandbox) = response - .sandboxes - .iter() - .find(|sandbox| sandbox.name == sandbox_name) - { - return Ok(Some(SandboxListEntry { - name: sandbox.name.clone(), - phase: sandbox.phase.clone(), - })); - } - if response.next_page_token.is_empty() { - return Ok(None); - } - - page_token = response.next_page_token; - page = page - .checked_add(1) - .ok_or_else(|| "sandbox list page counter overflowed".to_string())?; - } -} diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index 018ae54912..35c00db6af 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -900,6 +900,7 @@ dependencies = [ "jsonwebtoken", "nix", "noyalib", + "openshell-e2e-support", "prost", "rand", "rustls", @@ -919,6 +920,17 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-e2e-support" +version = "0.0.0" +dependencies = [ + "rand", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "parking_lot" version = "0.12.5" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 6b1fc73723..097bf50539 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -234,6 +234,7 @@ path = "tests/gpu.rs" required-features = ["e2e-gpu"] [dependencies] +openshell-e2e-support = { path = "../support/rust" } base64 = "0.22" bollard = "0.20" bytes = "1" diff --git a/e2e/rust/src/harness/mod.rs b/e2e/rust/src/harness/mod.rs index 86bf314a19..026ca26b26 100644 --- a/e2e/rust/src/harness/mod.rs +++ b/e2e/rust/src/harness/mod.rs @@ -3,11 +3,11 @@ //! Shared test harness modules for CLI e2e tests. -pub mod binary; +pub use openshell_e2e_support::binary; pub mod cli; pub mod container; pub mod gateway; pub mod host_process; -pub mod output; -pub mod port; +pub use openshell_e2e_support::output; +pub use openshell_e2e_support::port; pub mod sandbox; diff --git a/tests/suites/conformance/Cargo.lock b/e2e/suites/conformance/Cargo.lock similarity index 86% rename from tests/suites/conformance/Cargo.lock rename to e2e/suites/conformance/Cargo.lock index 89d8c0d392..e69774cab4 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/e2e/suites/conformance/Cargo.lock @@ -66,15 +66,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "memchr" version = "2.8.3" @@ -99,7 +90,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openshell-conformance" +name = "openshell-e2e-support" version = "0.0.0" dependencies = [ "rand", @@ -113,33 +104,13 @@ dependencies = [ name = "openshell-test-conformance-cli" version = "0.0.0" dependencies = [ - "openshell-conformance", + "openshell-e2e-support", + "serde", + "serde_json", + "tempfile", "tokio", ] -[[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 = "pin-project-lite" version = "0.2.17" @@ -208,15 +179,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "rustix" version = "1.1.5" @@ -230,12 +192,6 @@ dependencies = [ "windows-sys", ] -[[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" @@ -289,12 +245,6 @@ dependencies = [ "libc", ] -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - [[package]] name = "socket2" version = "0.6.5" @@ -349,7 +299,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/e2e/suites/conformance/Cargo.toml b/e2e/suites/conformance/Cargo.toml new file mode 100644 index 0000000000..7f7650ef0e --- /dev/null +++ b/e2e/suites/conformance/Cargo.toml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] +resolver = "2" +members = ["cli"] + +[workspace.lints.rust] +unsafe_code = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } +trivial_casts = "warn" +trivial_numeric_casts = "warn" +unused_lifetimes = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } + +# Allow certain pedantic lints that are too noisy +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" + +# Allow noisy nursery lints +significant_drop_tightening = "allow" # Often gives incorrect suggestions +missing_const_for_fn = "allow" # Too noisy for async code patterns + +# Allow noisy pedantic lints +too_many_lines = "allow" # Function length limits are subjective +needless_pass_by_value = "allow" # Common pattern in async handlers +ref_option = "allow" # Common pattern for optional references +missing_fields_in_debug = "allow" # Manual Debug impls often intentionally omit fields diff --git a/e2e/suites/conformance/README.md b/e2e/suites/conformance/README.md new file mode 100644 index 0000000000..e0ecece555 --- /dev/null +++ b/e2e/suites/conformance/README.md @@ -0,0 +1,127 @@ +# OpenShell conformance suite + +Conformance is an end-to-end test type for required public behavior against an +already configured gateway. This workspace contains Cargo tests organized by +conformance area and user story. The CLI is the current client interface; lifecycle, file transfer, and policy behavior define the test groups. + +## Layout + +- `cli/tests//main.rs`: declares story modules for one Cargo test target. +- `cli/tests//.rs`: test functions, steps, and assertions for a story. +- `cli/tests//helpers.rs`: setup and assertions shared by that area's stories. + +The current areas are `file_transfer`, `lifecycle`, `policy_advisor`, and `smoke`. +Each area compiles into one test binary; individual stories remain selectable by +their module prefix. `main.rs` contains only module declarations. + +Shared CLI execution, polling, diagnostics, and cleanup live in +[`e2e/support/rust`](../../support/rust/README.md), alongside the utilities used by +the existing Rust e2e tests. Story modules implement their tests directly; Cargo and nextest provide discovery and selection. Product crates must +not depend on the test tooling. + +For example, run only file-transfer round trips against a prepared gateway: + +```shell +OPENSHELL_BIN=/absolute/path/to/openshell \ + cargo test --locked --manifest-path e2e/suites/conformance/Cargo.toml \ + --test file_transfer round_trip:: +``` + +## Run without a gateway + +Run shared tooling and scenario unit tests: + +```shell +cargo test --locked --manifest-path e2e/support/rust/Cargo.toml +cargo test --locked --manifest-path e2e/suites/conformance/Cargo.toml \ + --package openshell-test-conformance-cli --test policy_advisor draft_assertion::tests:: +``` + +Compile the gateway-backed entry points without executing them: + +```shell +cargo test --locked --manifest-path e2e/suites/conformance/Cargo.toml \ + --package openshell-test-conformance-cli --no-run +``` + +Repository formatting, Clippy, and unit-test checks include this workspace. +The unit-test commands select only the pure policy assertion tests. Running every +workspace test also executes the gateway-backed cases. + +## Run against a prepared gateway + +Install, register, and select the gateway before starting the suite. Supply the +matching candidate CLI explicitly: + +```shell +OPENSHELL_BIN=/absolute/path/to/openshell \ + cargo test --locked --manifest-path e2e/suites/conformance/Cargo.toml \ + --package openshell-test-conformance-cli --no-fail-fast -- \ + --test-threads=1 --nocapture +``` + +For development, use a suite, CLI, and gateway from the same candidate build. +Cross-version qualification is not defined. The suite does not install OpenShell, start a gateway, or select a compute driver. The target +must supply the workload image and tools used by the selected scenarios. Missing +CLI or gateway prerequisites fail the run rather than silently passing. + +Run one group with `--test file_transfer`, `--test lifecycle`, +`--test policy_advisor`, or `--test smoke`, before the `--` separator. A group or +individual-test filter exercises only part of the suite. + +The tmachine conformance testsuite runs these same entry points from the +nextest archive built by `build-openshell-conformance-test-archive`. See +[CI.md](../../../CI.md) for the implemented integration lanes and +[the test-guest guide](../../../nix/test-guest/README.md) for preparation +and artifact execution. Archive construction selects the CLI test package; +harness unit tests run separately in source checks. + +## Add or change a test + +Document the expected public behavior, preconditions, required permissions, and +significant side effects beside each scenario implementation. Use descriptive, +stable test names so results can be tracked across runs. Add the Cargo entry point +in the behavior group it exercises. Preserve existing test names when moving code so +Cargo and nextest filters keep working. + +Use structured CLI output and sandbox operations for behavioral assertions. +Separate end-user and administrative interactions into different tests. Tests +may modify resources and settings through public APIs within their declared +permissions; deployment configuration belongs to target preparation. Keep +runtime inspection and host-specific assertions in driver suites. Behavioral +assertions must not depend on public internet services. + +Tests must clean up their changes. Use unique resource names, register resources +before creation, and call `OpenShellRunner::finish` even when the scenario fails so it +attempts cleanup. Cleanup failures must remain visible. Current policy-advisor +cases set the proposal setting on their own sandbox; the suite runs serially. + +## Failures and evidence + +Every conformance test checks required behavior. A failing test on a supported +target records a conformance gap; missing support must not turn the result into +a passing test. Cargo currently reports ordinary test success or failure. + +Investigate failures and track their product, test, or infrastructure causes. +Retain the result and failure logs with the revision, target configuration, and +run duration. Notify the responsible maintainers of new failures. Do not +configure automatic retries for failed tests. + +An explicit waiver records its scope, rationale, and tracking issue while +retaining the failed result. Waivers are review decisions, not changes to test +assertions or successful results. Automated evidence retention, notifications, +and waiver handling remain implementation work. + +## Migration scope + +The conformance, feature, and driver suite workspaces live under `e2e/suites/`; +target provisioning stays under `tests/`. Existing +assertions and test identities are preserved. The legacy mixed suites under +`e2e/rust/` still need classification and migration by behavioral intent. + +[RFC 0016](https://github.com/NVIDIA/OpenShell/pull/3460) also proposes independent +conformance, feature, and driver PR selection, release qualification, and later +restart/upgrade disruption tests. Those execution changes and packaging for runs +without a source checkout are separate follow-ups in +[#3954](https://github.com/NVIDIA/OpenShell/issues/3954). See [CI.md](../../../CI.md) for the +implemented gates; this layout change does not establish those proposed gates. diff --git a/tests/suites/drivers/podman/Cargo.toml b/e2e/suites/conformance/cli/Cargo.toml similarity index 59% rename from tests/suites/drivers/podman/Cargo.toml rename to e2e/suites/conformance/cli/Cargo.toml index 1a06b69e8d..1b1a7cd738 100644 --- a/tests/suites/drivers/podman/Cargo.toml +++ b/e2e/suites/conformance/cli/Cargo.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [package] -name = "openshell-test-suite-podman" +name = "openshell-test-conformance-cli" version = "0.0.0" edition = "2024" rust-version = "1.94" @@ -10,6 +10,11 @@ license = "Apache-2.0" repository = "https://github.com/NVIDIA/OpenShell" [dependencies] -openshell-conformance = { path = "../../../../crates/openshell-conformance" } serde = { version = "1", features = ["derive"] } -tokio = { version = "1.43", features = ["macros", "rt"] } +serde_json = "1" +tempfile = "3" +openshell-e2e-support = { path = "../../../support/rust" } +tokio = { version = "1.43", features = ["macros", "process", "rt", "time"] } + +[lints] +workspace = true diff --git a/e2e/suites/conformance/cli/tests/file_transfer/git_filtering.rs b/e2e/suites/conformance/cli/tests/file_transfer/git_filtering.rs new file mode 100644 index 0000000000..507caf2228 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/file_transfer/git_filtering.rs @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{ + delete_sandbox, download, exec, fs_error, git, git_init, prepare_sandbox, require_absent, + require_exists, require_text, upload, upload_result, +}; +use openshell_e2e_support::OpenShellRunner; +use std::fs; +use std::path::Path; + +/// Verify Git-aware upload selection and explicit unfiltered uploads. +#[tokio::test] +async fn respects_git_selection_and_explicit_override() { + let mut runner = OpenShellRunner::from_env("file-transfer/git-filtering") + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "git-filtering").await?; + gitignore_filtering(runner, &sandbox_name, &remote_root, local.path()).await?; + single_file_from_git_repo(runner, &sandbox_name, &remote_root, local.path()).await?; + gitignored_directory_requires_override(runner, &sandbox_name, &remote_root, local.path()) + .await?; + delete_sandbox(runner, &sandbox_name).await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("file-transfer/git-filtering conformance story failed:\n{error}"); + } +} + +async fn gitignore_filtering( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let repository = local_root.join("filter-repo"); + fs::create_dir(&repository).map_err(fs_error("create filter repository"))?; + git_init(&repository).await?; + fs::write(repository.join(".gitignore"), "*.log\nbuild/\n") + .map_err(fs_error("write filter .gitignore"))?; + fs::write(repository.join("tracked.txt"), "i-am-tracked") + .map_err(fs_error("write tracked.txt"))?; + fs::write(repository.join("ignored.log"), "i-should-be-filtered") + .map_err(fs_error("write ignored.log"))?; + fs::create_dir(repository.join("build")).map_err(fs_error("create ignored build directory"))?; + fs::write(repository.join("build/output.bin"), "build-artifact") + .map_err(fs_error("write ignored build artifact"))?; + git(&repository, &["add", "."]).await?; + + let remote = format!("{remote_root}/filtered"); + upload( + runner, + sandbox, + "gitignore/upload", + &repository, + &remote, + false, + ) + .await?; + + let destination = local_root.join("filter-download"); + fs::create_dir(&destination).map_err(fs_error("create filter destination"))?; + download(runner, sandbox, "gitignore/download", &remote, &destination).await?; + let uploaded = destination.join("filter-repo"); + require_text( + &uploaded.join("tracked.txt"), + "i-am-tracked", + "Git-aware tracked file", + )?; + require_exists(&uploaded.join(".gitignore"), "uploaded .gitignore")?; + require_absent(&uploaded.join("ignored.log"), "Git-ignored file")?; + require_absent(&uploaded.join("build"), "Git-ignored directory") +} + +async fn single_file_from_git_repo( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let repository = local_root.join("single-repo"); + fs::create_dir_all(repository.join("nested")) + .map_err(fs_error("create single-file repository"))?; + git_init(&repository).await?; + fs::write(repository.join(".gitignore"), "*.log\n") + .map_err(fs_error("write single-file .gitignore"))?; + fs::write( + repository.join("nested/config.txt"), + "single-file-from-repo", + ) + .map_err(fs_error("write repository config.txt"))?; + fs::write(repository.join("tracked.txt"), "should-not-upload") + .map_err(fs_error("write unrelated tracked.txt"))?; + fs::write(repository.join("ignored.log"), "ignored") + .map_err(fs_error("write repository ignored.log"))?; + + let remote = format!("{remote_root}/single-from-repo"); + upload( + runner, + sandbox, + "single-from-repo/upload", + &repository.join("nested/config.txt"), + &remote, + false, + ) + .await?; + let destination = local_root.join("single-repo-download"); + fs::create_dir(&destination).map_err(fs_error("create single-repo destination"))?; + download( + runner, + sandbox, + "single-from-repo/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("config.txt"), + "single-file-from-repo", + "single file selected from repository", + )?; + require_absent( + &destination.join("tracked.txt"), + "unselected repository file", + )?; + require_absent(&destination.join("ignored.log"), "ignored repository file") +} + +async fn gitignored_directory_requires_override( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote_seed = format!("{remote_root}/runs/test.json"); + exec( + runner, + sandbox, + "gitignored-override/seed", + &format!("mkdir -p '{remote_root}/runs' && printf downloaded-payload > '{remote_seed}'"), + ) + .await?; + + let repository = local_root.join("override-repo"); + fs::create_dir(&repository).map_err(fs_error("create override repository"))?; + git_init(&repository).await?; + fs::write(repository.join(".gitignore"), "runs/\n") + .map_err(fs_error("write override .gitignore"))?; + let runs = repository.join("runs"); + fs::create_dir(&runs).map_err(fs_error("create ignored runs directory"))?; + download( + runner, + sandbox, + "gitignored-override/download-seed", + &remote_seed, + &runs, + ) + .await?; + require_exists(&runs.join("test.json"), "downloaded ignored file")?; + + let remote = format!("{remote_root}/reuploaded"); + let rejected = upload_result( + runner, + sandbox, + "gitignored-override/reject-upload", + &runs, + &remote, + false, + ) + .await?; + let output = format!("{}\n{}", rejected.stdout(), rejected.stderr()); + if rejected.success() + || !output.contains("filtering selected no files") + || !output.contains("--no-git-ignore") + { + return Err(rejected.failure_diagnostic( + "upload rejects an empty Git selection and explains the explicit override", + )); + } + exec( + runner, + sandbox, + "gitignored-override/no-transfer", + &format!("test ! -e '{remote}'"), + ) + .await?; + upload( + runner, + sandbox, + "gitignored-override/upload", + &runs, + &remote, + true, + ) + .await?; + + let destination = local_root.join("override-download"); + fs::create_dir(&destination).map_err(fs_error("create override destination"))?; + download( + runner, + sandbox, + "gitignored-override/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("runs/test.json"), + "downloaded-payload", + "re-uploaded Git-ignored file", + ) +} diff --git a/e2e/suites/conformance/cli/tests/file_transfer/helpers.rs b/e2e/suites/conformance/cli/tests/file_transfer/helpers.rs new file mode 100644 index 0000000000..2bffcf5da1 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/file_transfer/helpers.rs @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_e2e_support::CommandResult; +use openshell_e2e_support::OpenShellRunner; +use std::fs; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; + +pub const CREATE_TIMEOUT: Duration = Duration::from_mins(10); +pub const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); +pub const TRANSFER_TIMEOUT: Duration = Duration::from_mins(5); +pub const LARGE_FILE_SIZE: usize = 512 * 1024; + +pub async fn prepare_sandbox( + runner: &mut OpenShellRunner, + group: &str, +) -> Result<(String, String, tempfile::TempDir), String> { + let suffix = match group { + "round-trip" => "fr", + "git-filtering" => "fg", + "path-safety" => "fs", + _ => return Err(format!("unknown file-transfer group {group:?}")), + }; + let sandbox_name = format!("ct-{}-{suffix}", runner.id()); + let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", runner.id()); + let local = + tempfile::tempdir().map_err(|error| format!("create temporary directory: {error}"))?; + + runner.track_sandbox(&sandbox_name); + let create = runner + .step(format!("{group}/create")) + .description(format!("sandbox '{sandbox_name}' is created")) + .with_timeout(CREATE_TIMEOUT) + .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + + exec( + runner, + &sandbox_name, + &format!("{group}/prepare"), + &format!("mkdir -p '{remote_root}'"), + ) + .await?; + + Ok((sandbox_name, remote_root, local)) +} + +pub async fn delete_sandbox( + runner: &mut OpenShellRunner, + sandbox_name: &str, +) -> Result<(), String> { + let delete = runner + .step("delete") + .description(format!("sandbox '{sandbox_name}' is deleted")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "delete", sandbox_name]) + .await + .map_err(|error| error.to_string())?; + delete.require_success()?; + runner.forget_sandbox(sandbox_name); + Ok(()) +} + +pub async fn upload( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &Path, + destination: &str, + no_git_ignore: bool, +) -> Result<(), String> { + let result = upload_result(runner, sandbox, step, source, destination, no_git_ignore).await?; + result.require_success() +} + +pub async fn upload_result( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &Path, + destination: &str, + no_git_ignore: bool, +) -> Result { + let source = source + .to_str() + .ok_or_else(|| format!("local upload path is not UTF-8: {}", source.display()))?; + let mut args = vec!["sandbox", "upload", sandbox, source, destination]; + if no_git_ignore { + args.push("--no-git-ignore"); + } + runner + .step(step) + .description(format!("upload {source:?} to {destination:?}")) + .with_timeout(TRANSFER_TIMEOUT) + .run(&args) + .await + .map_err(|error| error.to_string()) +} + +pub async fn download( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &str, + destination: &Path, +) -> Result<(), String> { + let result = download_result(runner, sandbox, step, source, destination).await?; + result.require_success() +} + +pub async fn download_result( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + source: &str, + destination: &Path, +) -> Result { + let destination = destination.to_str().ok_or_else(|| { + format!( + "local download path is not UTF-8: {}", + destination.display() + ) + })?; + runner + .step(step) + .description(format!( + "download {source:?} to {destination:?} has the expected disposition" + )) + .with_timeout(TRANSFER_TIMEOUT) + .run(&["sandbox", "download", sandbox, source, destination]) + .await + .map_err(|error| error.to_string()) +} + +pub async fn exec( + runner: &OpenShellRunner, + sandbox: &str, + step: &str, + script: &str, +) -> Result<(), String> { + let result = runner + .step(step) + .description(format!("sandbox fixture setup for {step} succeeds")) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", script, + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success() +} + +pub async fn git_init(repository: &Path) -> Result<(), String> { + git(repository, &["init", "--quiet"]).await +} + +pub async fn git(repository: &Path, args: &[&str]) -> Result<(), String> { + let output = Command::new("git") + .args(args) + .current_dir(repository) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|error| format!("failed to run git in {}: {error}", repository.display()))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "git {} failed in {} with status {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + repository.display(), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )) +} + +pub fn require_text(path: &Path, expected: &str, label: &str) -> Result<(), String> { + let actual = fs::read_to_string(path) + .map_err(|error| format!("read {label} at {}: {error}", path.display()))?; + if actual == expected { + Ok(()) + } else { + Err(format!( + "{label} content mismatch at {}: expected {expected:?}, received {actual:?}", + path.display() + )) + } +} + +pub fn require_exists(path: &Path, label: &str) -> Result<(), String> { + if path.exists() { + Ok(()) + } else { + Err(format!("{label} does not exist at {}", path.display())) + } +} + +pub fn require_absent(path: &Path, label: &str) -> Result<(), String> { + if path.exists() { + Err(format!("{label} unexpectedly exists at {}", path.display())) + } else { + Ok(()) + } +} + +pub fn fs_error(context: &'static str) -> impl FnOnce(std::io::Error) -> String { + move |error| format!("{context}: {error}") +} diff --git a/e2e/suites/conformance/cli/tests/file_transfer/main.rs b/e2e/suites/conformance/cli/tests/file_transfer/main.rs new file mode 100644 index 0000000000..ccf0da9468 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/file_transfer/main.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! File transfer conformance stories. + +mod git_filtering; +mod helpers; +mod path_safety; +mod round_trip; diff --git a/e2e/suites/conformance/cli/tests/file_transfer/path_safety.rs b/e2e/suites/conformance/cli/tests/file_transfer/path_safety.rs new file mode 100644 index 0000000000..3dd4509e32 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/file_transfer/path_safety.rs @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{ + delete_sandbox, download, download_result, exec, fs_error, prepare_sandbox, require_absent, + require_text, +}; +use openshell_e2e_support::OpenShellRunner; +use std::fs; +use std::path::Path; + +/// Verify workspace boundary enforcement and safe filename handling. +#[tokio::test] +async fn enforces_workspace_boundary_and_handles_filenames() { + let mut runner = OpenShellRunner::from_env("file-transfer/path-safety") + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "path-safety").await?; + reject_workspace_escape(runner, &sandbox_name, &remote_root, local.path()).await?; + download_dash_leading_name(runner, &sandbox_name, &remote_root, local.path()).await?; + delete_sandbox(runner, &sandbox_name).await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("file-transfer/path-safety conformance story failed:\n{error}"); + } +} + +async fn reject_workspace_escape( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let etc_link = format!("{remote_root}/etc-link"); + let passwd_link = format!("{remote_root}/passwd-link"); + exec( + runner, + sandbox, + "workspace-escape/seed", + &format!("ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'"), + ) + .await?; + let destination = local_root.join("workspace-escape"); + fs::create_dir(&destination).map_err(fs_error("create workspace-escape destination"))?; + + for (step, source) in [ + ("directory-link", etc_link.clone()), + ("file-link", passwd_link), + ("linked-component", format!("{etc_link}/passwd")), + ] { + let result = download_result( + runner, + sandbox, + &format!("workspace-escape/{step}"), + &source, + &destination, + ) + .await?; + if result.success() { + return Err(format!( + "download unexpectedly accepted sandbox path {source:?} that resolves outside the workspace" + )); + } + let diagnostic = format!("{}\n{}", result.stdout(), result.stderr()); + if !diagnostic.contains("resolves to") + || !diagnostic.contains("outside the") + || !diagnostic.contains("sandbox workspace") + { + return Err(result.failure_diagnostic( + "download is rejected because the resolved source is outside the sandbox workspace", + )); + } + } + require_absent(&destination.join("passwd"), "escaped passwd file")?; + require_absent(&destination.join("etc-link"), "escaped /etc directory") +} + +async fn download_dash_leading_name( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote = format!("{remote_root}/--checkpoint-action=evil"); + exec( + runner, + sandbox, + "dash-leading/seed", + &format!("printf dash-payload > '{remote}'"), + ) + .await?; + let destination = local_root.join("dash-leading"); + fs::create_dir(&destination).map_err(fs_error("create dash-leading destination"))?; + download( + runner, + sandbox, + "dash-leading/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("--checkpoint-action=evil"), + "dash-payload", + "dash-leading file", + ) +} diff --git a/e2e/suites/conformance/cli/tests/file_transfer/round_trip.rs b/e2e/suites/conformance/cli/tests/file_transfer/round_trip.rs new file mode 100644 index 0000000000..671814203b --- /dev/null +++ b/e2e/suites/conformance/cli/tests/file_transfer/round_trip.rs @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{ + LARGE_FILE_SIZE, delete_sandbox, download, exec, fs_error, prepare_sandbox, require_text, + upload, upload_result, +}; +use openshell_e2e_support::OpenShellRunner; +use std::fs; +use std::path::Path; + +/// Verify file and directory upload and download round trips. +#[tokio::test] +async fn transfers_files_and_directories() { + let mut runner = OpenShellRunner::from_env("file-transfer/round-trip") + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?; + round_trip(runner, &sandbox_name, &remote_root, local.path()).await?; + download_file(runner, &sandbox_name, &remote_root, local.path()).await?; + download_directory(runner, &sandbox_name, &remote_root, local.path()).await?; + delete_sandbox(runner, &sandbox_name).await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("file-transfer/round-trip conformance story failed:\n{error}"); + } +} + +async fn round_trip( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let source = local_root.join("roundtrip-upload"); + fs::create_dir_all(source.join("subdir")).map_err(fs_error("create round-trip source"))?; + fs::write(source.join("greeting.txt"), "hello-from-local") + .map_err(fs_error("write greeting.txt"))?; + fs::write(source.join("subdir/nested.txt"), "nested-content") + .map_err(fs_error("write nested.txt"))?; + + let large = (0u8..=250) + .cycle() + .take(LARGE_FILE_SIZE) + .collect::>(); + fs::write(source.join("large.bin"), &large).map_err(fs_error("write large.bin"))?; + + let remote = format!("{remote_root}/roundtrip"); + let result = + upload_result(runner, sandbox, "roundtrip/upload", &source, &remote, false).await?; + result.require_success()?; + if !result.stderr().contains("outside a Git work tree") + || !result.stderr().contains(".gitignore rules are not applied") + { + return Err( + result.failure_diagnostic("upload outside Git warns that filtering is disabled") + ); + } + + let destination = local_root.join("roundtrip-download"); + fs::create_dir(&destination).map_err(fs_error("create round-trip destination"))?; + let remote_source = format!("{remote}/roundtrip-upload"); + download( + runner, + sandbox, + "roundtrip/download", + &remote_source, + &destination, + ) + .await?; + + require_text( + &destination.join("greeting.txt"), + "hello-from-local", + "round-trip greeting", + )?; + require_text( + &destination.join("subdir/nested.txt"), + "nested-content", + "round-trip nested file", + )?; + let actual = fs::read(destination.join("large.bin")).map_err(fs_error("read large.bin"))?; + if actual != large { + return Err(format!( + "large file changed during round trip: expected {} bytes, received {} bytes", + large.len(), + actual.len() + )); + } + + let single = local_root.join("single.txt"); + fs::write(&single, "single-file-payload").map_err(fs_error("write single.txt"))?; + let remote_single = format!("{remote_root}/single.txt"); + upload( + runner, + sandbox, + "single/upload", + &single, + &remote_single, + false, + ) + .await?; + let single_destination = local_root.join("single-download"); + fs::create_dir(&single_destination).map_err(fs_error("create single-file destination"))?; + download( + runner, + sandbox, + "single/download", + &remote_single, + &single_destination, + ) + .await?; + require_text( + &single_destination.join("single.txt"), + "single-file-payload", + "single-file round trip", + ) +} + +async fn download_file( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote = format!("{remote_root}/download-file.txt"); + exec( + runner, + sandbox, + "download-file/seed", + &format!("printf greeting-payload > '{remote}'"), + ) + .await?; + let destination = local_root.join("download-file"); + fs::create_dir(&destination).map_err(fs_error("create file-download destination"))?; + download( + runner, + sandbox, + "download-file/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("download-file.txt"), + "greeting-payload", + "downloaded file", + ) +} + +async fn download_directory( + runner: &OpenShellRunner, + sandbox: &str, + remote_root: &str, + local_root: &Path, +) -> Result<(), String> { + let remote = format!("{remote_root}/tree"); + exec( + runner, + sandbox, + "download-directory/seed", + &format!( + "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && printf nested > '{remote}/sub/child.txt'" + ), + ) + .await?; + let destination = local_root.join("download-directory"); + fs::create_dir(&destination).map_err(fs_error("create directory-download destination"))?; + download( + runner, + sandbox, + "download-directory/download", + &remote, + &destination, + ) + .await?; + require_text( + &destination.join("root.txt"), + "top-level", + "downloaded directory root file", + )?; + require_text( + &destination.join("sub/child.txt"), + "nested", + "downloaded directory nested file", + ) +} diff --git a/e2e/suites/conformance/cli/tests/lifecycle/delete_stopped.rs b/e2e/suites/conformance/cli/tests/lifecycle/delete_stopped.rs new file mode 100644 index 0000000000..486c17a460 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/lifecycle/delete_stopped.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{ + create_running_sandbox, run_lifecycle_command, wait_for_absence, wait_for_phase, +}; +use openshell_e2e_support::OpenShellRunner; + +/// Verify a stopped sandbox can be deleted. +#[tokio::test] +async fn deletes_a_stopped_sandbox() { + let mut runner = OpenShellRunner::from_env("sandbox-lifecycle/delete-stopped") + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let sandbox_name = format!("ct-{}-sd", runner.id()); + create_running_sandbox( + runner, + &sandbox_name, + "exec sleep infinity", + "stopped-delete", + ) + .await?; + + run_lifecycle_command(runner, "stop", &sandbox_name, "stopped-delete/stop").await?; + let sandbox = + wait_for_phase(runner, &sandbox_name, "Stopped", "stopped-delete/stopped").await?; + run_lifecycle_command(runner, "delete", &sandbox_name, "stopped-delete/delete").await?; + wait_for_absence(runner, &sandbox.id, &sandbox_name, "stopped-delete/deleted").await?; + runner.forget_sandbox(&sandbox_name); + Ok(()) + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("sandbox-lifecycle/delete-stopped conformance story failed:\n{error}"); + } +} diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/e2e/suites/conformance/cli/tests/lifecycle/helpers.rs similarity index 63% rename from crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs rename to e2e/suites/conformance/cli/tests/lifecycle/helpers.rs index a0b1c0016d..f972141d80 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/e2e/suites/conformance/cli/tests/lifecycle/helpers.rs @@ -1,139 +1,32 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Portable sandbox lifecycle conformance scenarios. - -use std::collections::HashSet; -use std::time::{Duration, Instant}; - +use openshell_e2e_support::OpenShellRunner; +use openshell_e2e_support::Poll; use serde::Deserialize; +use std::collections::HashSet; +use std::time::Duration; +use std::time::Instant; -use crate::{OpenShellRunner, Poll, Scenario, ScenarioFuture}; - -const CREATE_TIMEOUT: Duration = Duration::from_mins(10); -const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); -const TRANSITION_TIMEOUT: Duration = Duration::from_mins(4); -const TRANSITION_INTERVAL: Duration = Duration::from_secs(2); +pub const CREATE_TIMEOUT: Duration = Duration::from_mins(10); +pub const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); +pub const TRANSITION_TIMEOUT: Duration = Duration::from_mins(4); +pub const TRANSITION_INTERVAL: Duration = Duration::from_secs(2); #[derive(Debug, Deserialize)] -struct SandboxState { - id: String, - name: String, - phase: String, +pub struct SandboxState { + pub id: String, + pub name: String, + pub phase: String, } #[derive(Debug, Deserialize)] -struct SandboxListPage { - sandboxes: Vec, - next_page_token: String, -} - -/// Certify sandbox stop, start, and deletion lifecycle behavior. -pub const SANDBOX_LIFECYCLE_SCENARIO: Scenario = Scenario { - name: "sandbox-lifecycle", - run: run_sandbox_lifecycle, -}; - -fn run_sandbox_lifecycle(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - stop_start_preserves_workspace(runner).await?; - stopped_can_be_deleted(runner).await - }) -} - -async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result<(), String> { - let sandbox_name = format!("ct-{}-ss", runner.id()); - let sentinel = format!("openshell-stop-start-{}", runner.id()); - let sentinel_path = "/sandbox/.openshell-stop-start-sentinel"; - let run_count_path = "/sandbox/.openshell-main-run-count"; - let main = format!( - "count=0; test ! -f '{run_count_path}' || count=$(cat '{run_count_path}'); \ - count=$((count + 1)); printf '%s\\n' \"$count\" > '{run_count_path}'; \ - exec sleep infinity" - ); - - create_running_sandbox(runner, &sandbox_name, &main, "stop-start").await?; - exec_expect_exact( - runner, - &sandbox_name, - "write-sentinel", - &[ - "sh", - "-lc", - &format!("printf '%s\\n' '{sentinel}' > '{sentinel_path}' && sync"), - ], - "", - ) - .await?; - - run_lifecycle_command(runner, "stop", &sandbox_name, "stop-start/stop").await?; - wait_for_phase(runner, &sandbox_name, "Stopped", "stop-start/stopped").await?; - - let stopped_exec = runner - .step("stop-start/exec-while-stopped") - .description(format!( - "sandbox '{sandbox_name}' rejects exec while stopped" - )) - .with_timeout(COMMAND_TIMEOUT) - .run(&[ - "sandbox", - "exec", - "--name", - &sandbox_name, - "--no-tty", - "--", - "cat", - sentinel_path, - ]) - .await - .map_err(|error| error.to_string())?; - if stopped_exec.success() { - return Err( - stopped_exec.failure_diagnostic("sandbox exec fails while the sandbox is stopped") - ); - } - - run_lifecycle_command(runner, "start", &sandbox_name, "stop-start/start").await?; - wait_for_phase(runner, &sandbox_name, "Ready", "stop-start/restarted").await?; - - exec_expect_exact( - runner, - &sandbox_name, - "read-sentinel", - &["cat", sentinel_path], - &format!("{sentinel}\n"), - ) - .await?; - exec_expect_exact( - runner, - &sandbox_name, - "read-main-run-count", - &["cat", run_count_path], - "2\n", - ) - .await -} - -async fn stopped_can_be_deleted(runner: &mut OpenShellRunner) -> Result<(), String> { - let sandbox_name = format!("ct-{}-sd", runner.id()); - create_running_sandbox( - runner, - &sandbox_name, - "exec sleep infinity", - "stopped-delete", - ) - .await?; - - run_lifecycle_command(runner, "stop", &sandbox_name, "stopped-delete/stop").await?; - let sandbox = - wait_for_phase(runner, &sandbox_name, "Stopped", "stopped-delete/stopped").await?; - run_lifecycle_command(runner, "delete", &sandbox_name, "stopped-delete/delete").await?; - wait_for_absence(runner, &sandbox.id, &sandbox_name, "stopped-delete/deleted").await?; - runner.forget_sandbox(&sandbox_name); - Ok(()) +pub struct SandboxListPage { + pub sandboxes: Vec, + pub next_page_token: String, } -async fn create_running_sandbox( +pub async fn create_running_sandbox( runner: &mut OpenShellRunner, sandbox_name: &str, main: &str, @@ -164,7 +57,7 @@ async fn create_running_sandbox( .map(|_| ()) } -async fn run_lifecycle_command( +pub async fn run_lifecycle_command( runner: &OpenShellRunner, operation: &str, sandbox_name: &str, @@ -180,7 +73,7 @@ async fn run_lifecycle_command( result.require_success() } -async fn exec_expect_exact( +pub async fn exec_expect_exact( runner: &OpenShellRunner, sandbox_name: &str, step: &str, @@ -204,7 +97,7 @@ async fn exec_expect_exact( } } -async fn wait_for_phase( +pub async fn wait_for_phase( runner: &mut OpenShellRunner, sandbox_name: &str, expected_phase: &str, @@ -254,7 +147,7 @@ async fn wait_for_phase( .map_err(|error| error.to_string()) } -async fn wait_for_absence( +pub async fn wait_for_absence( runner: &mut OpenShellRunner, sandbox_id: &str, sandbox_name: &str, @@ -283,7 +176,7 @@ async fn wait_for_absence( .map_err(|error| error.to_string()) } -async fn sandbox_is_listed( +pub async fn sandbox_is_listed( runner: &OpenShellRunner, sandbox_id: &str, sandbox_name: &str, diff --git a/e2e/suites/conformance/cli/tests/lifecycle/main.rs b/e2e/suites/conformance/cli/tests/lifecycle/main.rs new file mode 100644 index 0000000000..a7958f54fb --- /dev/null +++ b/e2e/suites/conformance/cli/tests/lifecycle/main.rs @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lifecycle conformance stories. + +mod delete_stopped; +mod helpers; +mod stop_start; diff --git a/e2e/suites/conformance/cli/tests/lifecycle/stop_start.rs b/e2e/suites/conformance/cli/tests/lifecycle/stop_start.rs new file mode 100644 index 0000000000..8acd8c4ecf --- /dev/null +++ b/e2e/suites/conformance/cli/tests/lifecycle/stop_start.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{ + COMMAND_TIMEOUT, create_running_sandbox, exec_expect_exact, run_lifecycle_command, + wait_for_phase, +}; +use openshell_e2e_support::OpenShellRunner; + +/// Verify stop/start preserves the workspace and restarts the main process. +#[tokio::test] +async fn preserves_workspace_and_restarts_main_process() { + let mut runner = OpenShellRunner::from_env("sandbox-lifecycle/stop-start") + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let sandbox_name = format!("ct-{}-ss", runner.id()); + let sentinel = format!("openshell-stop-start-{}", runner.id()); + let sentinel_path = "/sandbox/.openshell-stop-start-sentinel"; + let run_count_path = "/sandbox/.openshell-main-run-count"; + let main = format!( + "count=0; test ! -f '{run_count_path}' || count=$(cat '{run_count_path}'); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > '{run_count_path}'; \ + exec sleep infinity" + ); + + create_running_sandbox(runner, &sandbox_name, &main, "stop-start").await?; + exec_expect_exact( + runner, + &sandbox_name, + "write-sentinel", + &[ + "sh", + "-lc", + &format!("printf '%s\\n' '{sentinel}' > '{sentinel_path}' && sync"), + ], + "", + ) + .await?; + + run_lifecycle_command(runner, "stop", &sandbox_name, "stop-start/stop").await?; + wait_for_phase(runner, &sandbox_name, "Stopped", "stop-start/stopped").await?; + + let stopped_exec = runner + .step("stop-start/exec-while-stopped") + .description(format!( + "sandbox '{sandbox_name}' rejects exec while stopped" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &sandbox_name, + "--no-tty", + "--", + "cat", + sentinel_path, + ]) + .await + .map_err(|error| error.to_string())?; + if stopped_exec.success() { + return Err( + stopped_exec.failure_diagnostic("sandbox exec fails while the sandbox is stopped") + ); + } + + run_lifecycle_command(runner, "start", &sandbox_name, "stop-start/start").await?; + wait_for_phase(runner, &sandbox_name, "Ready", "stop-start/restarted").await?; + + exec_expect_exact( + runner, + &sandbox_name, + "read-sentinel", + &["cat", sentinel_path], + &format!("{sentinel}\n"), + ) + .await?; + exec_expect_exact( + runner, + &sandbox_name, + "read-main-run-count", + &["cat", run_count_path], + "2\n", + ) + .await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("sandbox-lifecycle/stop-start conformance story failed:\n{error}"); + } +} diff --git a/e2e/suites/conformance/cli/tests/policy_advisor/draft_assertion.rs b/e2e/suites/conformance/cli/tests/policy_advisor/draft_assertion.rs new file mode 100644 index 0000000000..b680f0744d --- /dev/null +++ b/e2e/suites/conformance/cli/tests/policy_advisor/draft_assertion.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Pure assertions for a single scoped mechanistic policy draft. + +pub struct ExpectedDraft<'a> { + pub rule: &'a str, + pub endpoint: &'a str, + pub binary: &'a str, +} + +pub fn assert_mechanistic_draft(output: &str, expected: &ExpectedDraft<'_>) -> Result<(), String> { + let fields = output.lines().map(str::trim).collect::>(); + let field = |name: &str| { + fields + .iter() + .find_map(|line| line.strip_prefix(name).map(str::trim)) + }; + let endpoints = format!("{} [L4]", expected.endpoint); + if fields + .iter() + .filter(|line| line.starts_with("Chunk:")) + .count() + != 1 + || !matches!(field("Status:"), Some("pending" | "approved")) + || field("Rule:") != Some(expected.rule) + || field("Binary:") != Some(expected.binary) + || field("Binaries:") != Some(expected.binary) + || field("Endpoints:") != Some(endpoints.as_str()) + || !field("Rationale:").is_some_and(|value| value.contains(expected.endpoint)) + { + return Err(format!( + "expected one pending or approved L4 mechanistic draft scoped to {} and {}", + expected.binary, expected.endpoint + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ExpectedDraft, assert_mechanistic_draft}; + + const EXPECTED: ExpectedDraft<'static> = ExpectedDraft { + rule: "allow_pypi_org_80", + endpoint: "pypi.org:80", + binary: "/usr/bin/bash", + }; + + fn draft(binary: &str) -> String { + format!( + "Chunk: id\nStatus: pending\nRule: allow_pypi_org_80\nBinary: {binary}\nRationale: Allow {binary} to connect to pypi.org:80 (HTTP).\nEndpoints: pypi.org:80 [L4]\nBinaries: {binary}\n" + ) + } + + #[test] + fn draft_assertion_accepts_a_hostname_scoped_draft() { + assert!(assert_mechanistic_draft(&draft("/usr/bin/bash"), &EXPECTED).is_ok()); + } + + #[test] + fn draft_assertion_rejects_unrelated_binary() { + assert!(assert_mechanistic_draft(&draft("/usr/bin/sh"), &EXPECTED).is_err()); + } + + #[test] + fn draft_assertion_rejects_fields_spread_across_drafts() { + let drafts = format!( + "{}Chunk: other\nStatus: pending\nRule: allow_1_1_1_1_443\n", + draft("/usr/bin/bash") + ); + assert!(assert_mechanistic_draft(&drafts, &EXPECTED).is_err()); + } +} diff --git a/e2e/suites/conformance/cli/tests/policy_advisor/helpers.rs b/e2e/suites/conformance/cli/tests/policy_advisor/helpers.rs new file mode 100644 index 0000000000..6ab6403b78 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/policy_advisor/helpers.rs @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::draft_assertion::{ExpectedDraft, assert_mechanistic_draft}; +use openshell_e2e_support::OpenShellRunner; +use serde::Deserialize; +use serde_json::Value; +use std::time::Duration; +use std::time::Instant; +use tokio::time::sleep; + +pub const CREATE_TIMEOUT: Duration = Duration::from_mins(10); +pub const COMMAND_TIMEOUT: Duration = Duration::from_secs(45); +pub const READY_TIMEOUT: Duration = Duration::from_mins(4); +pub const POLL_INTERVAL: Duration = Duration::from_secs(2); +pub const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(90); + +#[derive(Deserialize)] +pub struct SandboxState { + pub name: String, + pub phase: String, +} + +pub const EMPTY_NETWORK_POLICY: &[u8] = br"version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /bin, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: { compatibility: best_effort } +network_policies: {} +"; + +pub async fn sandbox_bash_path(runner: &OpenShellRunner, name: &str) -> Result { + let result = runner + .step("bash-binary") + .description("the sandbox's Bash executable has a canonical path") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + name, + "--no-tty", + "--", + "bash", + "-c", + "printf '%s\\n' \"$(readlink -f /proc/$$/exe)\"", + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + let binary = result.stdout().trim(); + if !binary.starts_with('/') + || binary.contains('\n') + || binary.rsplit('/').next() != Some("bash") + { + return Err(result.failure_diagnostic("one absolute Bash executable path ending in /bash")); + } + Ok(binary.to_string()) +} + +pub async fn enable_proposals(runner: &OpenShellRunner, name: &str) -> Result<(), String> { + let set = runner + .step("enable-policy-advisor") + .description("sandbox-scoped policy advisor setting is enabled") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "settings", + "set", + name, + "--key", + "agent_policy_proposals_enabled", + "--value", + "true", + ]) + .await + .map_err(|error| error.to_string())?; + set.require_success()?; + + let settings = runner + .step("effective-settings") + .description("policy advisor setting is effectively enabled") + .with_timeout(COMMAND_TIMEOUT) + .run(&["settings", "get", name, "--json"]) + .await + .map_err(|error| error.to_string())?; + settings.require_success()?; + let value: Value = settings.json().map_err(|error| error.to_string())?; + if value["settings"]["agent_policy_proposals_enabled"]["value"] != "true" { + return Err(settings.failure_diagnostic( + "effective agent_policy_proposals_enabled is true; check for a global override", + )); + } + Ok(()) +} + +pub async fn await_mechanistic_draft( + runner: &OpenShellRunner, + sandbox: &str, + expected: &ExpectedDraft<'_>, + probe_stderr: &str, +) -> Result<(), String> { + let started = Instant::now(); + loop { + let draft = runner + .step("mechanistic-draft") + .description("a single scoped mechanistic draft appears") + .with_timeout(COMMAND_TIMEOUT) + .run(&["rule", "get", sandbox]) + .await + .map_err(|error| error.to_string())?; + if !draft.success() { + if started.elapsed() >= PROPOSAL_TIMEOUT { + return Err(draft.failure_diagnostic("the reviewer inbox is readable")); + } + sleep(POLL_INTERVAL).await; + continue; + } + if !draft.stdout().contains("Chunk:") { + if started.elapsed() >= PROPOSAL_TIMEOUT { + return Err(draft.failure_diagnostic(&format!( + "one mechanistic draft for {} and {}; probe stderr:\n{probe_stderr}", + expected.endpoint, expected.binary + ))); + } + sleep(POLL_INTERVAL).await; + continue; + } + return assert_mechanistic_draft(draft.stdout(), expected) + .map_err(|error| draft.failure_diagnostic(&error)); + } +} + +pub async fn create_sandbox( + runner: &mut OpenShellRunner, + name: &str, + policy_path: Option<&str>, +) -> Result<(), String> { + runner.track_sandbox(name); + let mut args = vec![ + "sandbox", + "create", + "--name", + name, + "--detach", + "--no-tty", + "--no-auto-providers", + ]; + if let Some(path) = policy_path { + args.extend(["--policy", path]); + } + args.extend(["--", "sh", "-c", "exec sleep infinity"]); + let create = runner + .step("create") + .description(format!("sandbox '{name}' is created")) + .with_timeout(CREATE_TIMEOUT) + .run(&args) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + + let started = Instant::now(); + loop { + let get = runner + .step("ready") + .description(format!("sandbox '{name}' reaches Ready")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "get", name, "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + if get.success() { + let state: SandboxState = get.json().map_err(|error| error.to_string())?; + if state.name != name { + return Err(get.failure_diagnostic("sandbox get returns the created name")); + } + if state.phase == "Ready" { + return Ok(()); + } + if state.phase == "Failed" { + return Err(get.failure_diagnostic("sandbox reaches Ready instead of Failed")); + } + } + if started.elapsed() >= READY_TIMEOUT { + return Err(get.failure_diagnostic("sandbox reaches Ready before timeout")); + } + sleep(POLL_INTERVAL).await; + } +} diff --git a/e2e/suites/conformance/cli/tests/policy_advisor/main.rs b/e2e/suites/conformance/cli/tests/policy_advisor/main.rs new file mode 100644 index 0000000000..c9a449fbaa --- /dev/null +++ b/e2e/suites/conformance/cli/tests/policy_advisor/main.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Policy advisor conformance stories. + +mod draft_assertion; +mod helpers; +mod mechanistic_proposal; +mod new_hostname_proposal; +mod policy_local; diff --git a/e2e/suites/conformance/cli/tests/policy_advisor/mechanistic_proposal.rs b/e2e/suites/conformance/cli/tests/policy_advisor/mechanistic_proposal.rs new file mode 100644 index 0000000000..d3183f932b --- /dev/null +++ b/e2e/suites/conformance/cli/tests/policy_advisor/mechanistic_proposal.rs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::draft_assertion::ExpectedDraft; +use super::helpers::{ + COMMAND_TIMEOUT, EMPTY_NETWORK_POLICY, await_mechanistic_draft, create_sandbox, + enable_proposals, +}; +use openshell_e2e_support::OpenShellRunner; +use serde_json::Value; +use std::io::Write as _; +use tempfile::NamedTempFile; + +/// Turn a denied transparent TCP open into a scoped policy draft. +#[tokio::test] +async fn creates_a_scoped_draft_for_a_denied_endpoint() { + let mut runner = OpenShellRunner::from_env("mechanistic-proposal") + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; + policy + .write_all(EMPTY_NETWORK_POLICY) + .map_err(|error| error.to_string())?; + let policy_path = policy + .path() + .to_str() + .ok_or("temporary policy path is not UTF-8")?; + let name = format!("ct-{}-mp", runner.id()); + create_sandbox(runner, &name, Some(policy_path)).await?; + enable_proposals(runner, &name).await?; + + let effective = runner + .step("effective-policy") + .description("sandbox has no network allow rules before the probe") + .with_timeout(COMMAND_TIMEOUT) + .run(&["policy", "get", &name, "--full", "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + effective.require_success()?; + let value: Value = effective.json().map_err(|error| error.to_string())?; + let network_rules = &value["policy"]["network_policies"]; + if !network_rules.is_null() + && !network_rules + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + return Err(effective.failure_diagnostic("effective network_policies is empty")); + } + + let probe = runner + .step("denied-tcp-open") + .description("one Bash TCP open to 1.1.1.1:443 is denied by policy") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &name, + "--no-tty", + "--", + "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 + .map_err(|error| error.to_string())?; + probe.require_success()?; + let binary = probe + .stdout() + .lines() + .find_map(|line| line.strip_prefix("BINARY=")) + .filter(|binary| binary.starts_with('/') && binary.rsplit('/').next() == Some("bash")) + .ok_or_else(|| probe.failure_diagnostic("canonical Bash executable path is reported"))? + .to_string(); + if !probe.stdout().lines().any(|line| line == "DENIED") { + return Err(probe.failure_diagnostic("TCP open is denied before any upstream dial")); + } + + await_mechanistic_draft( + runner, + &name, + &ExpectedDraft { + rule: "allow_1_1_1_1_443", + endpoint: "1.1.1.1:443", + binary: &binary, + }, + probe.stderr(), + ) + .await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("mechanistic-proposal conformance story failed:\n{error}"); + } +} diff --git a/e2e/suites/conformance/cli/tests/policy_advisor/new_hostname_proposal.rs b/e2e/suites/conformance/cli/tests/policy_advisor/new_hostname_proposal.rs new file mode 100644 index 0000000000..88c47d7b21 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/policy_advisor/new_hostname_proposal.rs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::draft_assertion::ExpectedDraft; +use super::helpers::{ + COMMAND_TIMEOUT, EMPTY_NETWORK_POLICY, await_mechanistic_draft, create_sandbox, + sandbox_bash_path, +}; +use openshell_e2e_support::OpenShellRunner; +use std::io::Write as _; +use tempfile::NamedTempFile; + +/// Turn a denied TCP open to a hostname absent from policy into a scoped draft. +#[tokio::test] +async fn creates_a_scoped_draft_for_a_new_hostname() { + let mut runner = OpenShellRunner::from_env("new-hostname-proposal") + .expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let mut policy = NamedTempFile::new().map_err(|error| error.to_string())?; + policy + .write_all(EMPTY_NETWORK_POLICY) + .map_err(|error| error.to_string())?; + let policy_path = policy + .path() + .to_str() + .ok_or("temporary policy path is not UTF-8")?; + let name = format!("ct-{}-nh", runner.id()); + create_sandbox(runner, &name, Some(policy_path)).await?; + let binary = sandbox_bash_path(runner, &name).await?; + + let probe = runner + .step("denied-new-hostname") + .description("Bash cannot connect to pypi.org:80 before approval") + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &name, + "--no-tty", + "--", + "bash", + "-c", + "if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await + .map_err(|error| error.to_string())?; + probe.require_success()?; + if !probe.stdout().lines().any(|line| line == "DENIED") { + return Err(probe.failure_diagnostic("new hostname stays denied before approval")); + } + + await_mechanistic_draft( + runner, + &name, + &ExpectedDraft { + rule: "allow_pypi_org_80", + endpoint: "pypi.org:80", + binary: &binary, + }, + probe.stderr(), + ) + .await + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("new-hostname-proposal conformance story failed:\n{error}"); + } +} diff --git a/e2e/suites/conformance/cli/tests/policy_advisor/policy_local.rs b/e2e/suites/conformance/cli/tests/policy_advisor/policy_local.rs new file mode 100644 index 0000000000..5561e3363a --- /dev/null +++ b/e2e/suites/conformance/cli/tests/policy_advisor/policy_local.rs @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{ + COMMAND_TIMEOUT, POLL_INTERVAL, READY_TIMEOUT, create_sandbox, enable_proposals, + sandbox_bash_path, +}; +use openshell_e2e_support::OpenShellRunner; +use serde_json::Value; +use std::time::Instant; +use tokio::time::sleep; + +/// Read and request a rule through the sandbox-local policy HTTP API. +#[tokio::test] +async fn submits_a_rule_for_review() { + let mut runner = + OpenShellRunner::from_env("policy-local").expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let name = format!("ct-{}-pl", runner.id()); + create_sandbox(runner, &name, None).await?; + enable_proposals(runner, &name).await?; + let binary = sandbox_bash_path(runner, &name).await?; + + let started = Instant::now(); + let readiness_path = format!("/v1/proposals/ct-{}-readiness", runner.id()); + loop { + match request_policy_local(runner, &name, "/v1/policy/current").await { + Ok(response) + if response["format"] == "yaml" + && response["policy_yaml"] + .as_str() + .is_some_and(|yaml| yaml.contains("version: 1")) => + { + // The current-policy route is local; proposal submission also + // needs the supervisor's workspace and gateway lookup session. + match request_policy_local_http(runner, &name, "GET", &readiness_path, "", 404) + .await + { + Ok(lookup) if lookup["error"] == "chunk_not_found" => break, + Ok(lookup) => { + return Err(format!( + "policy.local proposal lookup returned an invalid readiness response: {lookup}" + )); + } + Err(error) if started.elapsed() >= READY_TIMEOUT => return Err(error), + Err(_) => {} + } + } + Ok(response) => { + return Err(format!( + "policy.local returned an invalid current policy: {response}" + )); + } + Err(error) => { + if started.elapsed() >= READY_TIMEOUT { + return Err(error); + } + } + } + sleep(POLL_INTERVAL).await; + } + let denials = request_policy_local(runner, &name, "/v1/denials?last=1").await?; + if !denials["denials"].is_array() || !denials["log_available"].is_boolean() { + return Err(format!( + "policy.local returned an invalid denials response: {denials}" + )); + } + + let rule_name = format!("conformance_local_{}", runner.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 = + request_policy_local_http(runner, &name, "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(|| format!("policy.local did not accept one proposal: {submitted}"))?; + if submitted["status"] != "submitted" + || submitted["accepted_chunks"] != 1 + || submitted["rejected_chunks"] != 0 + { + return Err(format!("policy.local did not submit one rule: {submitted}")); + } + + let state = request_policy_local(runner, &name, &format!("/v1/proposals/{chunk_id}")).await?; + if state["chunk_id"] != chunk_id + || state["rule_name"] != rule_name + || state["binary"] != binary + || !matches!(state["status"].as_str(), Some("pending" | "approved")) + { + return Err(format!("policy.local returned the wrong proposal: {state}")); + } + + let review = runner + .step("reviewer-inbox") + .description("the requested rule is visible to the reviewer") + .with_timeout(COMMAND_TIMEOUT) + .run(&["rule", "get", &name]) + .await + .map_err(|error| error.to_string())?; + review.require_success()?; + if !review.stdout().contains(&format!("Chunk: {chunk_id}")) + || !review.stdout().contains(&format!("Rule: {rule_name}")) + { + return Err(review.failure_diagnostic("the submitted rule is in the reviewer inbox")); + } + Ok(()) + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("policy-local conformance story failed:\n{error}"); + } +} + +async fn request_policy_local( + runner: &OpenShellRunner, + sandbox: &str, + path: &str, +) -> Result { + request_policy_local_http(runner, sandbox, "GET", path, "", 200).await +} + +async fn request_policy_local_http( + runner: &OpenShellRunner, + sandbox: &str, + method: &str, + path: &str, + body: &str, + expected_status: u16, +) -> Result { + let script = "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 result = runner + .step(format!("policy-local-{method}{path}")) + .description(format!( + "{method} http://policy.local{path} succeeds from the sandbox" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + sandbox, + "--no-tty", + "--", + "bash", + "-c", + script, + "policy-local-http", + method, + path, + body, + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + let (headers, body) = result.stdout().split_once("\r\n\r\n").ok_or_else(|| { + result.failure_diagnostic("a complete HTTP response with headers and JSON body") + })?; + if !headers.starts_with(&format!("HTTP/1.1 {expected_status} ")) + || !headers.lines().any(|line| { + line.to_ascii_lowercase() + .starts_with("content-type: application/json") + }) + { + return Err( + result.failure_diagnostic(&format!("HTTP {expected_status} with JSON Content-Type")) + ); + } + serde_json::from_str(body) + .map_err(|error| result.failure_diagnostic(&format!("valid JSON body: {error}"))) +} diff --git a/e2e/suites/conformance/cli/tests/smoke/main.rs b/e2e/suites/conformance/cli/tests/smoke/main.rs new file mode 100644 index 0000000000..85e17395b3 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/smoke/main.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Smoke conformance stories. + +mod sandbox_lifecycle; diff --git a/e2e/suites/conformance/cli/tests/smoke/sandbox_lifecycle.rs b/e2e/suites/conformance/cli/tests/smoke/sandbox_lifecycle.rs new file mode 100644 index 0000000000..b5f8376327 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/smoke/sandbox_lifecycle.rs @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_e2e_support::OpenShellRunner; +use openshell_e2e_support::STATUS_TIMEOUT; +use serde::Deserialize; +use std::time::Duration; +use std::time::Instant; +use tokio::time::sleep; + +const CREATE_TIMEOUT: Duration = Duration::from_mins(10); +const LIST_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); +const LIST_PAGE_SIZE: u32 = 1_000; +const EXEC_TIMEOUT: Duration = Duration::from_mins(2); +const DELETE_TIMEOUT: Duration = Duration::from_mins(2); +const DELETE_POLL_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Debug, Deserialize)] +struct SandboxListEntry { + name: String, + phase: String, +} + +#[derive(Debug, Deserialize)] +struct SandboxListPage { + sandboxes: Vec, + next_page_token: String, +} + +/// Verify status, creation, listing, execution, and deletion through the CLI. +#[tokio::test] +async fn creates_executes_and_deletes_a_sandbox() { + let mut runner = + OpenShellRunner::from_env("smoke").expect("candidate openshell CLI is available"); + let result = async { + runner.check_gateway_status().await?; + let runner = &mut runner; + let status = runner + .step("status") + .description("openshell status succeeds") + .with_timeout(STATUS_TIMEOUT) + .run(&["status"]) + .await + .map_err(|error| error.to_string())?; + status.require_success()?; + + let sandbox_name = format!("ct-{}-01", runner.id()); + runner.track_sandbox(&sandbox_name); + let create = runner + .step("create") + .description("sandbox creation succeeds") + .with_timeout(CREATE_TIMEOUT) + .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + + let get = runner + .step("get-ready") + .description(format!("sandbox '{sandbox_name}' can be retrieved")) + .with_timeout(LIST_ATTEMPT_TIMEOUT) + .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + get.require_success()?; + + let sandbox = get + .json::() + .map_err(|error| error.to_string())?; + if sandbox.name != sandbox_name { + return Err(format!( + "sandbox get returned {:?}; expected sandbox '{sandbox_name}'", + sandbox.name + )); + } + if sandbox.phase != "Ready" { + return Err(format!( + "sandbox '{sandbox_name}' is in phase {:?}; expected Ready", + sandbox.phase + )); + } + + check_sandbox_listed(runner, &sandbox_name).await?; + + let marker = format!("openshell-conformance-{}", runner.id()); + let exec = runner + .step("exec") + .description("sandbox exec exits successfully") + .with_timeout(EXEC_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &sandbox_name, + "--no-tty", + "--", + "echo", + &marker, + ]) + .await + .map_err(|error| error.to_string())?; + exec.require_success()?; + let expected_stdout = format!("{marker}\n"); + if exec.stdout() != expected_stdout { + return Err(exec.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))); + } + + let delete = runner + .step("delete") + .description("sandbox deletion succeeds") + .with_timeout(DELETE_TIMEOUT) + .run(&["sandbox", "delete", &sandbox_name]) + .await + .map_err(|error| error.to_string())?; + delete.require_success()?; + + check_empty_list(runner, &sandbox_name).await?; + runner.forget_sandbox(&sandbox_name); + Ok(()) + } + .await; + if let Err(error) = runner.finish(result).await { + panic!("smoke conformance story failed:\n{error}"); + } +} + +async fn check_sandbox_listed(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + if find_sandbox(runner, sandbox_name, "list-visible") + .await? + .is_some() + { + return Ok(()); + } + Err(format!( + "sandbox '{sandbox_name}' does not appear in sandbox list" + )) +} + +async fn check_empty_list(runner: &OpenShellRunner, sandbox_name: &str) -> Result<(), String> { + let started = Instant::now(); + + loop { + match find_sandbox(runner, sandbox_name, "list-empty/query").await? { + None => return Ok(()), + Some(sandbox) if started.elapsed() >= DELETE_TIMEOUT => { + return Err(format!( + "sandbox '{sandbox_name}' remains listed in phase {:?} after {DELETE_TIMEOUT:.1?}", + sandbox.phase + )); + } + Some(_) => sleep(DELETE_POLL_INTERVAL).await, + } + } +} + +async fn find_sandbox( + runner: &OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result, String> { + let mut page_token = String::new(); + let mut page = 0u32; + + loop { + let page_size = LIST_PAGE_SIZE.to_string(); + let result = runner + .step(format!("{step}/{page}")) + .description(format!("sandbox list page {page} succeeds")) + .with_timeout(LIST_ATTEMPT_TIMEOUT) + .run(&[ + "sandbox", + "list", + "--page-size", + &page_size, + "--page-token", + &page_token, + "--output", + "json", + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + + let response = result + .json::() + .map_err(|error| error.to_string())?; + if let Some(sandbox) = response + .sandboxes + .iter() + .find(|sandbox| sandbox.name == sandbox_name) + { + return Ok(Some(SandboxListEntry { + name: sandbox.name.clone(), + phase: sandbox.phase.clone(), + })); + } + if response.next_page_token.is_empty() { + return Ok(None); + } + + page_token = response.next_page_token; + page = page + .checked_add(1) + .ok_or_else(|| "sandbox list page counter overflowed".to_string())?; + } +} diff --git a/e2e/suites/drivers/Cargo.lock b/e2e/suites/drivers/Cargo.lock new file mode 100644 index 0000000000..29c3ac91f3 --- /dev/null +++ b/e2e/suites/drivers/Cargo.lock @@ -0,0 +1,471 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-e2e-support" +version = "0.0.0" +dependencies = [ + "rand", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "openshell-test-suite-podman" +version = "0.0.0" +dependencies = [ + "futures-util", + "openshell-e2e-support", + "serde", + "tokio", + "toml_edit", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[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 = "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.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +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 = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +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.5", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[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" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/suites/drivers/Cargo.toml b/e2e/suites/drivers/Cargo.toml similarity index 100% rename from tests/suites/drivers/Cargo.toml rename to e2e/suites/drivers/Cargo.toml diff --git a/e2e/suites/drivers/podman/Cargo.toml b/e2e/suites/drivers/podman/Cargo.toml new file mode 100644 index 0000000000..7fb84d98a1 --- /dev/null +++ b/e2e/suites/drivers/podman/Cargo.toml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-suite-podman" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" + +[dependencies] +openshell-e2e-support = { path = "../../../support/rust" } +serde = { version = "1", features = ["derive"] } +tokio = { version = "1.43", features = ["macros", "rt", "process", "time", "io-util"] } + +[dev-dependencies] +futures-util = { version = "0.3", default-features = false, features = ["std"] } +toml_edit = "0.22" + +[features] +fixture-validation = [] diff --git a/e2e/suites/drivers/podman/README.md b/e2e/suites/drivers/podman/README.md new file mode 100644 index 0000000000..4f6f9f0ea4 --- /dev/null +++ b/e2e/suites/drivers/podman/README.md @@ -0,0 +1,56 @@ +# Podman driver tests + +These tests run inside a disposable tmachine guest against its shared Podman +gateway. Rootful and rootless Podman are separate tmachine environments. + +The `user_namespaces` Cargo test target contains four story modules: `default`, +`auto`, `keep_id`, and `private`. Each compares OpenShell's UID mapping with a +direct Podman container in the gateway service user's execution context, and +checks that the non-root workload owns and can write to its managed workspace. +Both commands use the same workload image. + +## Gateway fixture + +The Rust fixture requires `/etc/openshell/gateway.toml`, the +`openshell-gateway.service` systemd unit, a registered candidate CLI, and +passwordless sudo in the guest. tmachine prepares the writable fixture directory +at `/var/lib/openshell-driver-tests/podman`. This is a dedicated test gateway: +its baseline must omit `userns`, `uidmap`, and `gidmap` and contain no sandboxes. + +Ansible installs the archive and invokes nextest once with `--test-threads 1`. +A guest-side file lock also serializes separate Cargo or nextest invocations. +Under the lock, Rust captures the original config, applies the selected profile, +and restarts and checks gateway readiness. After success, an error, or an +assertion panic, it deletes tracked sandboxes, restores the exact original +configuration, restarts the gateway, and verifies health and sandbox absence. +Scenario failures and restoration failures remain test failures. + +Before changing the gateway, the fixture writes a dirty marker. It removes that +marker only after successful restoration. An interrupted process or failed +restoration therefore prevents subsequent stories from treating modified state +as their baseline. Start a fresh tmachine invocation to recover; each invocation +uses a disposable overlay over the cached installed guest image. + +## Run + +Build the candidate runtime inputs and driver archive, then run either environment: + +```shell +nix run .#build-podman-driver-test-archive +nix run .#tmachine -- test fedora-podman-rootful binaries driver-podman +nix run .#tmachine -- test fedora-podman-rootless binaries driver-podman +``` + +The tests may restart the gateway and require guest privileges. Run the pure +configuration and UID-map assertions on the development host with: + +```shell +cargo test --locked --manifest-path e2e/suites/drivers/Cargo.toml \ + --package openshell-test-suite-podman --test user_namespaces ::tests:: +``` + +Inside a prepared guest, a nextest test-name filter selects one profile. The +`fixture-validation` Cargo feature adds a deliberately failing panic scenario +for validating cleanup; it is excluded from normal archives. Execute that +scenario explicitly, require failure and successful restoration, then run a +normal profile in the same guest to verify recovery. diff --git a/tests/suites/drivers/podman/tests/support/mod.rs b/e2e/suites/drivers/podman/tests/support/mod.rs similarity index 97% rename from tests/suites/drivers/podman/tests/support/mod.rs rename to e2e/suites/drivers/podman/tests/support/mod.rs index 5bde19b0c1..1cdaf7100b 100644 --- a/tests/suites/drivers/podman/tests/support/mod.rs +++ b/e2e/suites/drivers/podman/tests/support/mod.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use openshell_conformance::OpenShellRunner; +use openshell_e2e_support::OpenShellRunner; use serde::Deserialize; use std::time::Duration; diff --git a/e2e/suites/drivers/podman/tests/user_namespaces/auto.rs b/e2e/suites/drivers/podman/tests/user_namespaces/auto.rs new file mode 100644 index 0000000000..08bf2ba3ed --- /dev/null +++ b/e2e/suites/drivers/podman/tests/user_namespaces/auto.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::gateway::{Profile, with_profile}; +use super::helpers::assert_workspace_and_uid_map; + +#[tokio::test] +async fn mapping_matches_podman_and_preserves_workspace_access() { + with_profile(Profile::Auto, async |gateway, runner| { + assert_workspace_and_uid_map(gateway, runner).await + }) + .await + .expect("auto Podman user namespace preserves workspace access"); +} diff --git a/e2e/suites/drivers/podman/tests/user_namespaces/default.rs b/e2e/suites/drivers/podman/tests/user_namespaces/default.rs new file mode 100644 index 0000000000..ef621982aa --- /dev/null +++ b/e2e/suites/drivers/podman/tests/user_namespaces/default.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::gateway::{Profile, with_profile}; +use super::helpers::assert_workspace_and_uid_map; + +#[tokio::test] +async fn mapping_matches_podman_and_preserves_workspace_access() { + with_profile(Profile::Default, async |gateway, runner| { + assert_workspace_and_uid_map(gateway, runner).await + }) + .await + .expect("default Podman user namespace preserves workspace access"); +} diff --git a/e2e/suites/drivers/podman/tests/user_namespaces/gateway.rs b/e2e/suites/drivers/podman/tests/user_namespaces/gateway.rs new file mode 100644 index 0000000000..327014f73e --- /dev/null +++ b/e2e/suites/drivers/podman/tests/user_namespaces/gateway.rs @@ -0,0 +1,414 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The caller must use a disposable tmachine guest with passwordless sudo. +//! A lock spans profile setup, assertions, sandbox cleanup, and restoration. +//! A dirty marker survives panic/termination or failed restoration: later tests +//! refuse to adopt a changed configuration as their baseline. + +use futures_util::FutureExt; +use openshell_e2e_support::OpenShellRunner; +use serde::Deserialize; +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::panic::AssertUnwindSafe; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::time::{sleep, timeout}; +use toml_edit::{DocumentMut, value}; + +use super::support::assert_podman_gateway; + +const CONFIG: &str = "/etc/openshell/gateway.toml"; +const SERVICE: &str = "openshell-gateway.service"; +const FIXTURE_DIR: &str = "/var/lib/openshell-driver-tests/podman"; +const DEFAULT_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Clone, Copy, Debug)] +pub enum Profile { + Default, + Auto, + KeepId, + Private, +} + +impl Profile { + fn arguments(self) -> &'static [&'static str] { + match self { + Self::Default => &[], + Self::Auto => &["--userns", "auto"], + Self::KeepId => &["--userns", "keep-id"], + // Podman infers private from explicit maps, and rejects combining + // --userns private with --uidmap/--gidmap. + Self::Private => &[ + "--uidmap", + "0:0:1", + "--uidmap", + "1:1:65535", + "--gidmap", + "0:0:1", + "--gidmap", + "1:1:65535", + ], + } + } + + fn configuration(self, original: &str) -> Result { + let mut config = original + .parse::() + .map_err(|error| error.to_string())?; + let driver = &mut config["openshell"]["drivers"]["podman"]; + if !driver.is_table() { + return Err("gateway configuration has no Podman driver table".into()); + } + match self { + Self::Default => {} + Self::Auto => driver["userns"] = value("auto"), + Self::KeepId => driver["userns"] = value("keep-id"), + Self::Private => { + driver["userns"] = value("private"); + driver["uidmap"] = value( + ["0:0:1", "1:1:65535"] + .into_iter() + .collect::(), + ); + driver["gidmap"] = value( + ["0:0:1", "1:1:65535"] + .into_iter() + .collect::(), + ); + } + } + Ok(config.to_string()) + } +} + +pub struct GatewayFixture { + _lock: File, + original: String, + dirty: PathBuf, + profile: Profile, + user: String, + home: String, + uid: String, + image: String, +} + +impl GatewayFixture { + async fn acquire(profile: Profile) -> Result { + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(PathBuf::from(FIXTURE_DIR).join("gateway.lock")) + .map_err(|error| { + format!("open gateway lock (tmachine preparation required): {error}") + })?; + let started = Instant::now(); + loop { + match lock.try_lock() { + Ok(()) => break, + Err(TryLockError::WouldBlock) if started.elapsed() < COMMAND_TIMEOUT => { + sleep(Duration::from_millis(100)).await; + } + Err(error) => return Err(format!("acquire shared gateway lock: {error}")), + } + } + let dirty = PathBuf::from(FIXTURE_DIR).join("gateway-dirty"); + if dirty.exists() { + return Err( + "previous test did not restore the gateway; start a fresh tmachine invocation" + .into(), + ); + } + let original = fs::read_to_string(CONFIG).map_err(|error| error.to_string())?; + let parsed = original + .parse::() + .map_err(|error| error.to_string())?; + let driver = &parsed["openshell"]["drivers"]["podman"]; + if !driver.is_table() + || ["userns", "uidmap", "gidmap"] + .iter() + .any(|key| driver.get(key).is_some()) + { + return Err("shared gateway baseline must omit userns, uidmap, and gidmap".into()); + } + let user = command( + "systemctl", + &["show", SERVICE, "--property=User", "--value"], + None, + ) + .await?; + let user: String = if user.trim().is_empty() { + "root".into() + } else { + user.trim().into() + }; + let account = command("getent", &["passwd", &user], None).await?; + let fields: Vec<_> = account.trim().split(':').collect(); + if fields.len() != 7 { + return Err("gateway service user has no valid passwd entry".into()); + } + let fixture = Self { + _lock: lock, + original, + dirty, + profile, + uid: fields[2].into(), + home: fields[5].into(), + user, + image: std::env::var("OPENSHELL_PODMAN_TEST_IMAGE") + .ok() + .filter(|image| !image.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_IMAGE.into()), + }; + normal_gateway().await?; + Ok(fixture) + } + + async fn apply(&self, runner: &mut OpenShellRunner) -> Result<(), String> { + // Set the marker before changing the gateway. The lock releases on + // process exit, but this marker must not be cleared by Drop. + fs::write(&self.dirty, format!("{:?}\n", self.profile)) + .map_err(|error| error.to_string())?; + let config = self.profile.configuration(&self.original)?; + command("sudo", &["-n", "tee", CONFIG], Some(&config)).await?; + restart_gateway(runner).await + } + + pub fn image(&self) -> &str { + &self.image + } + + async fn podman(&self, arguments: &[&str]) -> Result { + let home = format!("HOME={}", self.home); + let runtime = format!("XDG_RUNTIME_DIR=/run/user/{}", self.uid); + let mut args = vec!["-n", "-u", &self.user, "env", &home, &runtime, "podman"]; + args.extend_from_slice(arguments); + command("sudo", &args, None).await + } + + pub async fn reference_uid_map(&self) -> Result { + self.podman(&["pull", self.image()]).await?; + let mut args = vec!["run", "--rm", "--pull", "never"]; + args.extend_from_slice(self.profile.arguments()); + args.extend([ + "--entrypoint", + "/bin/cat", + self.image(), + "/proc/self/uid_map", + ]); + self.podman(&args).await + } + + async fn restore(&self) -> Result<(), String> { + // Deletion may be accepted while the gateway's cleanup worker is still + // running. Let it finish under the scenario profile before restarting. + // Even if that wait fails, attempt configuration restoration below. + if let Err(error) = normal_gateway().await { + eprintln!("gateway before restoration: {error}"); + } + command("sudo", &["-n", "tee", CONFIG], Some(&self.original)).await?; + command("sudo", &["-n", "systemctl", "restart", SERVICE], None).await?; + if fs::read_to_string(CONFIG).map_err(|error| error.to_string())? != self.original { + return Err("restored gateway configuration differs from the original".into()); + } + normal_gateway().await?; + fs::remove_file(&self.dirty).map_err(|error| error.to_string())?; + eprintln!("gateway restored: original configuration, healthy Podman gateway, no sandboxes"); + Ok(()) + } +} + +pub async fn with_profile(profile: Profile, scenario: F) -> Result<(), String> +where + F: AsyncFnOnce(&GatewayFixture, &mut OpenShellRunner) -> Result<(), String>, +{ + let fixture = GatewayFixture::acquire(profile).await?; + let mut runner = OpenShellRunner::from_env(&format!("podman-userns/{profile:?}")) + .map_err(|error| error.to_string())?; + let scenario_result = AssertUnwindSafe(async { + fixture.apply(&mut runner).await?; + scenario(&fixture, &mut runner).await + }) + .catch_unwind() + .await + .unwrap_or_else(|panic| { + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or("non-string panic"); + Err(format!("scenario panicked: {message}")) + }); + let result = runner.finish(scenario_result).await; + let restored = fixture.restore().await; + if result.is_err() || restored.is_err() { + let journal = command( + "sudo", + &[ + "-n", + "journalctl", + "--unit", + SERVICE, + "--no-pager", + "--lines", + "100", + ], + None, + ) + .await; + eprintln!( + "gateway failure diagnostics:\n{}", + journal.unwrap_or_else(|error| error) + ); + } + match (result, restored) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) => Err(error), + (Ok(()), Err(error)) => Err(format!("gateway restoration failed: {error}")), + (Err(error), Err(restore)) => Err(format!( + "{error}\ngateway restoration also failed: {restore}" + )), + } +} + +async fn restart_gateway(runner: &mut OpenShellRunner) -> Result<(), String> { + command("sudo", &["-n", "systemctl", "restart", SERVICE], None).await?; + runner.check_gateway_status().await?; + assert_podman_gateway(runner).await +} + +#[derive(Deserialize)] +struct SandboxPage { + sandboxes: Vec, + next_page_token: String, +} + +async fn normal_gateway() -> Result<(), String> { + let mut runner = + OpenShellRunner::from_env("podman-userns/baseline").map_err(|error| error.to_string())?; + let result = async { + runner.check_gateway_status().await?; + assert_podman_gateway(&runner).await?; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("timed out waiting for dedicated Podman test gateway to have no remaining sandboxes".into()); + } + let result = runner + .step("baseline/sandboxes") + .with_timeout(remaining.min(Duration::from_secs(10))) + .run(&["sandbox", "list", "--output", "json"]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + let page = result + .json::() + .map_err(|error| error.to_string())?; + if page.sandboxes.is_empty() && page.next_page_token.is_empty() { + break; + } + eprintln!("waiting for sandbox cleanup: {} entries remain on the first page", page.sandboxes.len()); + sleep(Duration::from_millis(250)).await; + } + Ok(()) + } + .await; + runner.finish(result).await +} + +async fn command(program: &str, arguments: &[&str], input: Option<&str>) -> Result { + let operation = async { + let mut child = Command::new(program) + .args(arguments) + .kill_on_drop(true) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| error.to_string())?; + if let Some(input) = input { + let mut stdin = child.stdin.take().ok_or("command stdin was not piped")?; + stdin + .write_all(input.as_bytes()) + .await + .map_err(|error| error.to_string())?; + } + let output = child + .wait_with_output() + .await + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(format!( + "{program} {arguments:?} exited {}:\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + }; + timeout(COMMAND_TIMEOUT, operation) + .await + .map_err(|_| format!("{program} {arguments:?} timed out"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profiles_only_change_podman_user_namespace_settings() { + let baseline = "[openshell.drivers.podman]\nsocket_path = '/run/podman/podman.sock'\n[other]\nvalue = 7\n"; + for profile in [ + Profile::Default, + Profile::Auto, + Profile::KeepId, + Profile::Private, + ] { + let config = profile + .configuration(baseline) + .unwrap() + .parse::() + .unwrap(); + assert_eq!(config["other"]["value"].as_integer(), Some(7)); + assert_eq!( + config["openshell"]["drivers"]["podman"]["socket_path"].as_str(), + Some("/run/podman/podman.sock") + ); + } + let private = Profile::Private + .configuration(baseline) + .unwrap() + .parse::() + .unwrap(); + assert_eq!( + private["openshell"]["drivers"]["podman"]["uidmap"] + .as_array() + .unwrap() + .len(), + 2 + ); + } +} + +// Executed explicitly during fixture validation, not normal qualification. +#[cfg(feature = "fixture-validation")] +#[tokio::test] +async fn deliberate_panic_after_sandbox_creation() { + with_profile(Profile::Auto, async |gateway, runner| { + super::helpers::assert_workspace_and_uid_map(gateway, runner).await?; + panic!("injected scenario assertion failure"); + }) + .await + .expect("injected failure must remain a failed test"); +} diff --git a/e2e/suites/drivers/podman/tests/user_namespaces/helpers.rs b/e2e/suites/drivers/podman/tests/user_namespaces/helpers.rs new file mode 100644 index 0000000000..eefede4a6c --- /dev/null +++ b/e2e/suites/drivers/podman/tests/user_namespaces/helpers.rs @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_e2e_support::OpenShellRunner; +use std::time::Duration; + +use super::gateway::GatewayFixture; + +const WORKSPACE_AND_UID_MAP_PROBE: &str = r#"set -eu +workload_owner="$(id -u):$(id -g)" +workspace_owner="$(stat -c '%u:%g' /sandbox)" +printf 'workload-owner=%s\nworkspace-owner=%s\n' "$workload_owner" "$workspace_owner" +test "$(id -u)" -ne 0 +test "$workspace_owner" = "$workload_owner" +probe=$(mktemp /sandbox/userns-probe.XXXXXX) +printf 'workspace probe\n' > "$probe" +rm "$probe" +echo podman-userns-workspace-ok +cat /proc/self/uid_map +"#; + +pub async fn assert_workspace_and_uid_map( + gateway: &GatewayFixture, + runner: &mut OpenShellRunner, +) -> Result<(), String> { + let reference = gateway.reference_uid_map().await?; + let expected = normalize_uid_map(&reference) + .ok_or_else(|| "direct Podman returned no UID mappings".to_string())?; + let sandbox_name = format!("pu-{}", runner.id()); + runner.track_sandbox(&sandbox_name); + let result = runner + .step("userns/workspace-and-uid-map") + .description("non-root sandbox owns and can write to its workspace") + .with_timeout(Duration::from_secs(300)) + .run(&[ + "sandbox", + "create", + "--name", + &sandbox_name, + "--from", + gateway.image(), + "--no-tty", + "--", + "sh", + "-c", + WORKSPACE_AND_UID_MAP_PROBE, + ]) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + if !result.stdout().contains("podman-userns-workspace-ok") { + return Err(result.failure_diagnostic("non-root workload owns and can write to /sandbox")); + } + let actual = normalize_uid_map(result.stdout()) + .ok_or_else(|| result.failure_diagnostic("sandbox returns a non-empty UID map"))?; + if actual != expected { + return Err(format!( + "UID map differs from direct Podman:\nexpected:\n{expected}\nactual:\n{actual}" + )); + } + Ok(()) +} + +fn normalize_uid_map(value: &str) -> Option { + let mut mappings: Vec<(u64, u64, u64)> = Vec::new(); + for line in value.lines() { + let fields = line + .split_whitespace() + .map(str::parse::) + .collect::, _>>(); + let Ok(fields) = fields else { continue }; + let [inside, outside, length] = fields.as_slice() else { + continue; + }; + if let Some(previous) = mappings.last_mut() + && previous.0.checked_add(previous.2) == Some(*inside) + && previous.1.checked_add(previous.2) == Some(*outside) + { + previous.2 += *length; + } else { + mappings.push((*inside, *outside, *length)); + } + } + (!mappings.is_empty()).then(|| { + mappings + .iter() + .map(|(inside, outside, length)| format!("{inside} {outside} {length}")) + .collect::>() + .join("\n") + }) +} + +#[cfg(test)] +mod tests { + use super::normalize_uid_map; + + #[test] + fn adjacent_uid_ranges_match_a_combined_mapping() { + assert_eq!( + normalize_uid_map("0 0 1\n1 1 65535\n"), + normalize_uid_map("0 0 65536\n") + ); + } +} diff --git a/e2e/suites/drivers/podman/tests/user_namespaces/keep_id.rs b/e2e/suites/drivers/podman/tests/user_namespaces/keep_id.rs new file mode 100644 index 0000000000..b01dfec512 --- /dev/null +++ b/e2e/suites/drivers/podman/tests/user_namespaces/keep_id.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::gateway::{Profile, with_profile}; +use super::helpers::assert_workspace_and_uid_map; + +#[tokio::test] +async fn mapping_matches_podman_and_preserves_workspace_access() { + with_profile(Profile::KeepId, async |gateway, runner| { + assert_workspace_and_uid_map(gateway, runner).await + }) + .await + .expect("keep_id Podman user namespace preserves workspace access"); +} diff --git a/e2e/suites/drivers/podman/tests/user_namespaces/main.rs b/e2e/suites/drivers/podman/tests/user_namespaces/main.rs new file mode 100644 index 0000000000..ca7c6199f1 --- /dev/null +++ b/e2e/suites/drivers/podman/tests/user_namespaces/main.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Podman profiles run serially against the tmachine guest's shared gateway. + +mod auto; +mod default; +mod gateway; +mod helpers; +mod keep_id; +mod private; +#[path = "../support/mod.rs"] +mod support; diff --git a/e2e/suites/drivers/podman/tests/user_namespaces/private.rs b/e2e/suites/drivers/podman/tests/user_namespaces/private.rs new file mode 100644 index 0000000000..e531f12012 --- /dev/null +++ b/e2e/suites/drivers/podman/tests/user_namespaces/private.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::gateway::{Profile, with_profile}; +use super::helpers::assert_workspace_and_uid_map; + +#[tokio::test] +async fn mapping_matches_podman_and_preserves_workspace_access() { + with_profile(Profile::Private, async |gateway, runner| { + assert_workspace_and_uid_map(gateway, runner).await + }) + .await + .expect("private Podman user namespace preserves workspace access"); +} diff --git a/tests/suites/features/Cargo.lock b/e2e/suites/features/Cargo.lock similarity index 99% rename from tests/suites/features/Cargo.lock rename to e2e/suites/features/Cargo.lock index 8561dc97df..2ac10407cd 100644 --- a/tests/suites/features/Cargo.lock +++ b/e2e/suites/features/Cargo.lock @@ -875,17 +875,6 @@ 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", - "serde", - "serde_json", - "tempfile", - "tokio", -] - [[package]] name = "openshell-e2e" version = "0.1.0" @@ -901,6 +890,7 @@ dependencies = [ "jsonwebtoken", "nix", "noyalib", + "openshell-e2e-support", "prost", "rand", "rustls", @@ -919,11 +909,21 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-e2e-support" +version = "0.0.0" +dependencies = [ + "rand", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "openshell-test-feature-provider-refresh-keycloak" version = "0.0.0" dependencies = [ - "openshell-conformance", "openshell-e2e", "serde_json", "tempfile", diff --git a/tests/suites/features/Cargo.toml b/e2e/suites/features/Cargo.toml similarity index 100% rename from tests/suites/features/Cargo.toml rename to e2e/suites/features/Cargo.toml diff --git a/tests/suites/features/provider-refresh/keycloak/Cargo.toml b/e2e/suites/features/provider-refresh/keycloak/Cargo.toml similarity index 73% rename from tests/suites/features/provider-refresh/keycloak/Cargo.toml rename to e2e/suites/features/provider-refresh/keycloak/Cargo.toml index f42a1c8bd7..666607f723 100644 --- a/tests/suites/features/provider-refresh/keycloak/Cargo.toml +++ b/e2e/suites/features/provider-refresh/keycloak/Cargo.toml @@ -7,8 +7,7 @@ version = "0.0.0" edition = "2024" [dependencies] -openshell-conformance = { path = "../../../../../crates/openshell-conformance" } -openshell-e2e = { path = "../../../../../e2e/rust" } +openshell-e2e = { path = "../../../../rust" } serde_json = "1" tempfile = "3" tokio = { version = "1.43", features = ["macros", "process", "io-util", "rt"] } diff --git a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs b/e2e/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs similarity index 100% rename from tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs rename to e2e/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs diff --git a/e2e/support/conformance.sh b/e2e/support/conformance.sh index d5b3e16ca4..979e4df21c 100644 --- a/e2e/support/conformance.sh +++ b/e2e/support/conformance.sh @@ -33,7 +33,7 @@ e2e_run_openshell_conformance() { echo "==> Running CLI conformance tests against the ${gateway_label} gateway" cargo test \ --locked \ - --manifest-path "${root}/tests/suites/conformance/Cargo.toml" \ + --manifest-path "${root}/e2e/suites/conformance/Cargo.toml" \ --package openshell-test-conformance-cli \ --no-fail-fast \ -- \ diff --git a/tests/suites/drivers/Cargo.lock b/e2e/support/rust/Cargo.lock similarity index 85% rename from tests/suites/drivers/Cargo.lock rename to e2e/support/rust/Cargo.lock index 6b013452fe..1c6fc5526e 100644 --- a/tests/suites/drivers/Cargo.lock +++ b/e2e/support/rust/Cargo.lock @@ -66,15 +66,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "memchr" version = "2.8.3" @@ -99,7 +90,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openshell-conformance" +name = "openshell-e2e-support" version = "0.0.0" dependencies = [ "rand", @@ -109,38 +100,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "openshell-test-suite-podman" -version = "0.0.0" -dependencies = [ - "openshell-conformance", - "serde", - "tokio", -] - -[[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 = "pin-project-lite" version = "0.2.17" @@ -209,15 +168,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "rustix" version = "1.1.5" @@ -231,12 +181,6 @@ dependencies = [ "windows-sys", ] -[[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" @@ -290,12 +234,6 @@ dependencies = [ "libc", ] -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - [[package]] name = "socket2" version = "0.6.5" @@ -350,7 +288,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/e2e/support/rust/Cargo.toml b/e2e/support/rust/Cargo.toml new file mode 100644 index 0000000000..c40bfb6257 --- /dev/null +++ b/e2e/support/rust/Cargo.toml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "openshell-e2e-support" +description = "Shared Rust tooling for OpenShell end-to-end tests" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +publish = false + +[dependencies] +rand = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tempfile = "3" +tokio = { version = "1.43", features = ["macros", "process", "rt", "time", "net"] } + +[lints.rust] +unsafe_code = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } +trivial_casts = "warn" +trivial_numeric_casts = "warn" +unused_lifetimes = "warn" +unused_qualifications = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } + +# Allow certain pedantic lints that are too noisy +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" + +# Allow noisy nursery lints +significant_drop_tightening = "allow" # Often gives incorrect suggestions +missing_const_for_fn = "allow" # Too noisy for async code patterns + +# Allow noisy pedantic lints +too_many_lines = "allow" # Function length limits are subjective +needless_pass_by_value = "allow" # Common pattern in async handlers +ref_option = "allow" # Common pattern for optional references +missing_fields_in_debug = "allow" # Manual Debug impls often intentionally omit fields diff --git a/e2e/support/rust/README.md b/e2e/support/rust/README.md new file mode 100644 index 0000000000..3ea4339656 --- /dev/null +++ b/e2e/support/rust/README.md @@ -0,0 +1,30 @@ +# Shared Rust e2e tooling + +`openshell-e2e-support` provides tooling for conformance, feature, driver, and +existing Rust e2e tests. It is a standalone test crate; product crates must not +depend on it. + +- The CLI runner captures stdout, stderr, exit status, duration, and diagnostic + context. Commands have explicit timeouts, and polling preserves the last + observation on failure. +- Resource tracking cleans up explicitly registered sandboxes through `finish()` + and retains the original test failure when cleanup also fails. +- `executor` provides process execution and an injectable boundary for unit tests. +- `binary` resolves `OPENSHELL_BIN` or a previously built checkout CLI and supports + PTY invocation. Archive-based suites use `OpenShellRunner::from_env()` to require + an explicit candidate binary. +- `output` parses CLI text and strips ANSI formatting. +- `port` provides TCP readiness checks and available-port discovery. + +Conformance scenarios remain in `e2e/suites/conformance/cli/tests`. Container +fixtures, gateway restart controls, and suite-specific workload defaults remain +in the existing `openshell-e2e` harness. That harness re-exports `binary`, `output`, +and `port` to preserve existing test imports. + +Run the tooling unit tests without a gateway: + +```shell +cargo test --locked --manifest-path e2e/support/rust/Cargo.toml +``` + +`mise run test:rust` and branch Rust checks include these tests. diff --git a/e2e/rust/src/harness/binary.rs b/e2e/support/rust/src/binary.rs similarity index 97% rename from e2e/rust/src/harness/binary.rs rename to e2e/support/rust/src/binary.rs index 4cf87c4c87..9d3af35c33 100644 --- a/e2e/rust/src/harness/binary.rs +++ b/e2e/support/rust/src/binary.rs @@ -12,10 +12,10 @@ use std::path::{Path, PathBuf}; /// Locate the workspace root by walking up from the crate's manifest directory. fn workspace_root() -> PathBuf { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - // e2e/rust/ is two levels below the workspace root. + // e2e/support/rust/ is three levels below the workspace root. manifest_dir .ancestors() - .nth(2) + .nth(3) .expect("failed to resolve workspace root from CARGO_MANIFEST_DIR") .to_path_buf() } diff --git a/crates/openshell-conformance/src/executor.rs b/e2e/support/rust/src/executor.rs similarity index 100% rename from crates/openshell-conformance/src/executor.rs rename to e2e/support/rust/src/executor.rs diff --git a/crates/openshell-conformance/src/lib.rs b/e2e/support/rust/src/lib.rs similarity index 97% rename from crates/openshell-conformance/src/lib.rs rename to e2e/support/rust/src/lib.rs index 4d1fcc1308..0f3a1f183d 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/e2e/support/rust/src/lib.rs @@ -1,17 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Reusable support for portable `OpenShell` CLI conformance scenarios. +//! Shared CLI runner and utilities for `OpenShell` end-to-end tests. +pub mod binary; pub mod executor; -mod scenarios; +pub mod output; +pub mod port; use std::collections::BTreeSet; use std::error::Error; use std::fmt; -use std::future::Future; use std::path::PathBuf; -use std::pin::Pin; use std::process::ExitStatus; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -23,28 +23,6 @@ use tokio::time::sleep; use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; -pub use scenarios::{ - FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, - NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, - SMOKE_SCENARIO, -}; - -/// A portable conformance scenario exercised by the Cargo test suite. -#[derive(Debug)] -pub struct Scenario { - pub name: &'static str, - run: for<'a> fn(&'a mut OpenShellRunner) -> ScenarioFuture<'a>, -} - -pub type ScenarioFuture<'a> = Pin> + Send + 'a>>; - -impl Scenario { - pub async fn run(&self, runner: &mut OpenShellRunner) -> Result<(), String> { - (self.run)(runner).await - } -} - const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2); pub const STATUS_TIMEOUT: Duration = Duration::from_secs(30); const GATEWAY_STATUS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); @@ -234,7 +212,7 @@ struct AuthenticationOutput { status: String, } -/// Runs `OpenShell` commands for one conformance scenario and owns its cleanup. +/// Runs `OpenShell` commands for one test scenario and owns its cleanup. pub struct OpenShellRunner { cli: Arc, run_id: String, diff --git a/e2e/rust/src/harness/output.rs b/e2e/support/rust/src/output.rs similarity index 100% rename from e2e/rust/src/harness/output.rs rename to e2e/support/rust/src/output.rs diff --git a/e2e/rust/src/harness/port.rs b/e2e/support/rust/src/port.rs similarity index 100% rename from e2e/rust/src/harness/port.rs rename to e2e/support/rust/src/port.rs diff --git a/tasks/rust.toml b/tasks/rust.toml index 32928b8f69..7937955b42 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -12,6 +12,9 @@ description = "Check all Rust crates for errors" depends = ["rust:lockfiles:check"] run = [ "cargo check --workspace", + "cargo check --locked --manifest-path e2e/support/rust/Cargo.toml --workspace --all-targets", + "cargo check --locked --manifest-path e2e/suites/conformance/Cargo.toml --workspace --all-targets", + "cargo check --locked --manifest-path e2e/suites/drivers/Cargo.toml --workspace --all-targets", "cargo check -p openshell-sandbox --all-targets --features perf-harness", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 check native" @@ -28,6 +31,9 @@ description = "Lint Rust code with Clippy (deny warnings)" depends = ["rust:lockfiles:check"] run = [ "cargo clippy --workspace --all-targets -- -D warnings", + "cargo clippy --locked --manifest-path e2e/support/rust/Cargo.toml --workspace --all-targets -- -D warnings", + "cargo clippy --locked --manifest-path e2e/suites/conformance/Cargo.toml --workspace --all-targets -- -D warnings", + "cargo clippy --locked --manifest-path e2e/suites/drivers/Cargo.toml --workspace --all-targets -- -D warnings", "cargo clippy -p openshell-sandbox --all-targets --features perf-harness -- -D warnings", "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", @@ -40,6 +46,9 @@ hide = true description = "Format Rust code" run = [ "cargo fmt --all", + "cargo fmt --manifest-path e2e/support/rust/Cargo.toml --all", + "cargo fmt --manifest-path e2e/suites/conformance/Cargo.toml --all", + "cargo fmt --manifest-path e2e/suites/drivers/Cargo.toml --all", "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", @@ -50,6 +59,9 @@ hide = true description = "Check Rust formatting" run = [ "cargo fmt --all -- --check", + "cargo fmt --manifest-path e2e/support/rust/Cargo.toml --all -- --check", + "cargo fmt --manifest-path e2e/suites/conformance/Cargo.toml --all -- --check", + "cargo fmt --manifest-path e2e/suites/drivers/Cargo.toml --all -- --check", "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", diff --git a/tasks/test.toml b/tasks/test.toml index 28be2d71f3..e6fa37eed6 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -103,6 +103,9 @@ run = [ # with test-only helpers enabled. "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", + "cargo test --locked --manifest-path e2e/support/rust/Cargo.toml", + "cargo test --locked --manifest-path e2e/suites/conformance/Cargo.toml --package openshell-test-conformance-cli --test policy_advisor draft_assertion::tests::", + "cargo test --locked --manifest-path e2e/suites/drivers/Cargo.toml --package openshell-test-suite-podman --test user_namespaces ::tests::", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" diff --git a/tests/ansible/playbooks/drivers/podman/default-userns-baseline.yaml b/tests/ansible/playbooks/drivers/podman/default-userns-baseline.yaml deleted file mode 100644 index 7356ae3c5e..0000000000 --- a/tests/ansible/playbooks/drivers/podman/default-userns-baseline.yaml +++ /dev/null @@ -1,106 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ---- -- name: Capture the default Podman user-namespace mapping - hosts: all - gather_facts: false - vars: - podman_reference_image: "{{ openshell_podman_reference_image }}" - podman_reference_uid_map: /var/lib/openshell-test-inputs/podman/reference-uid-map - tasks: - - name: Wait for SSH - ansible.builtin.wait_for_connection: - - - name: Detect tmachine container runtime - ansible.builtin.include_role: - name: tmachine_container_runtime - - - name: Require a Podman gateway - ansible.builtin.assert: - that: - - tmachine_container_runtime_name == "podman" - fail_msg: >- - The Podman default-userns suite requires a Podman gateway, not - {{ tmachine_container_runtime_name }} - - # podman_reference_user only selects who runs the direct `podman` - # reference commands below; it must match the daemon's rootful/rootless - # mode. The captured reference file itself stays owned by tmachine - # (the inventory's ansible_user), since the archived test binary that - # later reads it always runs unprivileged as tmachine, in both modes. - - name: Resolve the Podman reference execution context - ansible.builtin.set_fact: - podman_reference_user: "{{ 'tmachine' if tmachine_container_runtime_is_rootless else 'root' }}" - podman_reference_home: "{{ '/home/tmachine' if tmachine_container_runtime_is_rootless else '/root' }}" - podman_reference_uid: "{{ tmachine_container_runtime_tmachine_uid.stdout if tmachine_container_runtime_is_rootless else '0' }}" - - - name: Read OpenShell gateway configuration - become: true - ansible.builtin.slurp: - src: /etc/openshell/gateway.toml - register: openshell_gateway_config - - - name: Require unconfigured Podman user namespaces - ansible.builtin.assert: - that: - - >- - (openshell_gateway_config.content | b64decode) - is not regex('(?m)^\\s*(userns|uidmap|gidmap)\\s*=') - fail_msg: >- - The Podman default-userns suite requires gateway.toml to omit userns, - uidmap, and gidmap. - - - name: Create Podman test-input directory - become: true - ansible.builtin.file: - path: "{{ podman_reference_uid_map | dirname }}" - state: directory - owner: tmachine - group: tmachine - mode: "0700" - - - name: Pull the Podman reference image - become: true - become_user: "{{ podman_reference_user }}" - ansible.builtin.command: - argv: [podman, pull, "{{ podman_reference_image }}"] - environment: - HOME: "{{ podman_reference_home }}" - XDG_RUNTIME_DIR: "/run/user/{{ podman_reference_uid }}" - changed_when: false - - - name: Capture direct Podman default UID mapping - become: true - become_user: "{{ podman_reference_user }}" - ansible.builtin.command: - argv: - - podman - - run - - --rm - - --pull - - never - - --entrypoint - - /bin/cat - - "{{ podman_reference_image }}" - - /proc/self/uid_map - environment: - HOME: "{{ podman_reference_home }}" - XDG_RUNTIME_DIR: "/run/user/{{ podman_reference_uid }}" - changed_when: false - register: podman_default_uid_map - - - name: Require a direct Podman UID mapping - ansible.builtin.assert: - that: - - podman_default_uid_map.stdout | trim | length > 0 - fail_msg: Direct Podman reference container returned no UID mapping - - - name: Store direct Podman default UID mapping - become: true - ansible.builtin.copy: - content: "{{ podman_default_uid_map.stdout | trim }}\n" - dest: "{{ podman_reference_uid_map }}" - owner: tmachine - group: tmachine - mode: "0600" diff --git a/tests/ansible/playbooks/drivers/podman/tests.yaml b/tests/ansible/playbooks/drivers/podman/tests.yaml index abe2f6e553..9a73ecc5d7 100644 --- a/tests/ansible/playbooks/drivers/podman/tests.yaml +++ b/tests/ansible/playbooks/drivers/podman/tests.yaml @@ -7,7 +7,6 @@ gather_facts: false vars: podman_test_root: /var/lib/openshell-driver-tests/podman - podman_test_input_dir: /var/lib/openshell-test-inputs/podman tasks: - name: Wait for SSH ansible.builtin.wait_for_connection: @@ -51,11 +50,10 @@ - name: Run Podman archive tests ansible.builtin.command: - argv: [cargo-nextest, nextest, run, --archive-file, "{{ podman_test_root }}/tests.tar.zst", --workspace-remap, "{{ podman_test_root }}", --no-capture] + argv: [cargo-nextest, nextest, run, --archive-file, "{{ podman_test_root }}/tests.tar.zst", --workspace-remap, "{{ podman_test_root }}", --test-threads, "1", --success-output, immediate, --failure-output, immediate] environment: OPENSHELL_BIN: /usr/local/bin/openshell - OPENSHELL_TEST_INPUT_DIR: "{{ podman_test_input_dir }}" - OPENSHELL_PODMAN_TEST_IMAGE: "{{ openshell_podman_test_image | default('') }}" + OPENSHELL_PODMAN_TEST_IMAGE: "{{ openshell_podman_test_image | default(openshell_podman_reference_image) }}" register: podman_test_result changed_when: false failed_when: false diff --git a/tests/ansible/playbooks/drivers/podman/userns-auto.yaml b/tests/ansible/playbooks/drivers/podman/userns-auto.yaml deleted file mode 100644 index 353f902c2c..0000000000 --- a/tests/ansible/playbooks/drivers/podman/userns-auto.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ---- -- import_playbook: userns-profile.yaml - vars: - podman_userns_profile: auto - podman_userns_config: "{{ openshell_podman_userns_auto_config }}" - podman_userns_reference_args: [--userns, auto] diff --git a/tests/ansible/playbooks/drivers/podman/userns-keep-id.yaml b/tests/ansible/playbooks/drivers/podman/userns-keep-id.yaml deleted file mode 100644 index e7ec08cce4..0000000000 --- a/tests/ansible/playbooks/drivers/podman/userns-keep-id.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ---- -- import_playbook: userns-profile.yaml - vars: - podman_userns_profile: keep-id - podman_userns_config: "{{ openshell_podman_userns_keep_id_config }}" - podman_userns_reference_args: [--userns, keep-id] diff --git a/tests/ansible/playbooks/drivers/podman/userns-private.yaml b/tests/ansible/playbooks/drivers/podman/userns-private.yaml deleted file mode 100644 index d24c0f38bc..0000000000 --- a/tests/ansible/playbooks/drivers/podman/userns-private.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ---- -- import_playbook: userns-profile.yaml - vars: - podman_userns_profile: private - podman_userns_config: "{{ openshell_podman_userns_private_config }}" - podman_userns_reference_args: - # Podman infers a private namespace from explicit maps; its CLI rejects - # combining --userns private with --uidmap/--gidmap. - - --uidmap - - 0:0:1 - - --uidmap - - 1:1:65535 - - --gidmap - - 0:0:1 - - --gidmap - - 1:1:65535 diff --git a/tests/ansible/playbooks/drivers/podman/userns-profile.yaml b/tests/ansible/playbooks/drivers/podman/userns-profile.yaml deleted file mode 100644 index a3071c2833..0000000000 --- a/tests/ansible/playbooks/drivers/podman/userns-profile.yaml +++ /dev/null @@ -1,92 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - ---- -- name: Configure and capture a Podman user-namespace reference - hosts: all - gather_facts: false - vars: - podman_reference_image: "{{ openshell_podman_reference_image }}" - podman_reference_uid_map: /var/lib/openshell-test-inputs/podman/reference-uid-map - tasks: - - name: Wait for SSH - ansible.builtin.wait_for_connection: - - - name: Detect tmachine container runtime - ansible.builtin.include_role: - name: tmachine_container_runtime - - - name: Require a Podman gateway - ansible.builtin.assert: - that: - - tmachine_container_runtime_name == "podman" - fail_msg: >- - The Podman {{ podman_userns_profile }} suite requires a Podman gateway, not - {{ tmachine_container_runtime_name }} - - # podman_reference_user only selects who runs the direct `podman` - # reference command below; it must match the daemon's rootful/rootless - # mode. The captured reference file itself stays owned by tmachine - # (the inventory's ansible_user), since the archived test binary that - # later reads it always runs unprivileged as tmachine, in both modes. - - name: Resolve the Podman reference execution context - ansible.builtin.set_fact: - podman_reference_user: "{{ 'tmachine' if tmachine_container_runtime_is_rootless else 'root' }}" - podman_reference_home: "{{ '/home/tmachine' if tmachine_container_runtime_is_rootless else '/root' }}" - podman_reference_uid: "{{ tmachine_container_runtime_tmachine_uid.stdout if tmachine_container_runtime_is_rootless else '0' }}" - - - name: Apply the Podman user-namespace fixture - become: true - ansible.builtin.blockinfile: - path: /etc/openshell/gateway.toml - marker: "# {mark} OpenShell Podman userns test fixture" - block: "{{ lookup('ansible.builtin.file', podman_userns_config) | trim }}" - - - name: Restart OpenShell gateway with the Podman user-namespace fixture - become: true - ansible.builtin.systemd_service: - name: openshell-gateway.service - state: restarted - - - name: Wait for the configured OpenShell gateway - ansible.builtin.wait_for: - host: 127.0.0.1 - port: 17670 - timeout: 60 - - - name: Capture direct Podman user-namespace mapping - become: true - become_user: "{{ podman_reference_user }}" - ansible.builtin.command: - argv: "{{ [\"podman\", \"run\", \"--rm\", \"--pull\", \"never\"] + podman_userns_reference_args + [\"--entrypoint\", \"/bin/cat\", podman_reference_image, \"/proc/self/uid_map\"] }}" - environment: - HOME: "{{ podman_reference_home }}" - XDG_RUNTIME_DIR: "/run/user/{{ podman_reference_uid }}" - changed_when: false - register: podman_userns_uid_map - - - name: Require a direct Podman user-namespace mapping - ansible.builtin.assert: - that: - - podman_userns_uid_map.stdout | trim | length > 0 - fail_msg: >- - Direct Podman {{ podman_userns_profile }} reference container - returned no UID mapping - - - name: Create Podman test-input directory - become: true - ansible.builtin.file: - path: "{{ podman_reference_uid_map | dirname }}" - state: directory - owner: tmachine - group: tmachine - mode: "0700" - - - name: Store direct Podman user-namespace mapping - become: true - ansible.builtin.copy: - content: "{{ podman_userns_uid_map.stdout | trim }}\n" - dest: "{{ podman_reference_uid_map }}" - owner: tmachine - group: tmachine - mode: "0600" diff --git a/tests/artifacts.nix b/tests/artifacts.nix index a077a1ca2a..4e0921adb0 100644 --- a/tests/artifacts.nix +++ b/tests/artifacts.nix @@ -81,16 +81,16 @@ let conformanceTestArchive = mkTestArchive { name = "openshell-conformance"; - workspacePath = "tests/suites/conformance"; - manifestPath = "tests/suites/conformance/Cargo.toml"; + workspacePath = "e2e/suites/conformance"; + manifestPath = "e2e/suites/conformance/Cargo.toml"; package = "openshell-test-conformance-cli"; target = muslToolchain.target; output = "artifacts/test-archives/${muslToolchain.target}/openshell-conformance-tests.tar"; }; providerRefreshKeycloakArchive = mkTestArchive { name = "provider-refresh-keycloak"; - workspacePath = "tests/suites/features"; - manifestPath = "tests/suites/features/Cargo.toml"; + workspacePath = "e2e/suites/features"; + manifestPath = "e2e/suites/features/Cargo.toml"; package = "openshell-test-feature-provider-refresh-keycloak"; target = muslToolchain.target; output = "artifacts/test-archives/${muslToolchain.target}/provider-refresh-keycloak-tests.tar"; @@ -177,8 +177,8 @@ let podmanDriverArchive = mkTestArchive { name = "podman-driver"; - workspacePath = "tests/suites/drivers"; - manifestPath = "tests/suites/drivers/Cargo.toml"; + workspacePath = "e2e/suites/drivers"; + manifestPath = "e2e/suites/drivers/Cargo.toml"; package = "openshell-test-suite-podman"; target = muslToolchain.target; output = "artifacts/test-archives/${muslToolchain.target}/openshell-podman-tests.tar"; diff --git a/tests/config.nix b/tests/config.nix index 26d27b712b..3c0278e580 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -178,24 +178,12 @@ let } { name = "driver-podman"; - playbooks = [ - "ansible/playbooks/drivers/podman/default-userns-baseline.yaml" - "ansible/playbooks/drivers/podman/tests.yaml" - "ansible/playbooks/drivers/podman/userns-auto.yaml" - "ansible/playbooks/drivers/podman/tests.yaml" - "ansible/playbooks/drivers/podman/userns-keep-id.yaml" - "ansible/playbooks/drivers/podman/tests.yaml" - "ansible/playbooks/drivers/podman/userns-private.yaml" - "ansible/playbooks/drivers/podman/tests.yaml" - ]; + playbooks = [ "ansible/playbooks/drivers/podman/tests.yaml" ]; inputs = { openshell_podman_test_bundle = "../artifacts/test-archives/${muslTarget}/openshell-podman-tests.tar"; # Match OpenShell's compiled-in default so direct Podman and # OpenShell containers resolve the same workload image metadata. openshell_podman_reference_image = "nvcr.io/nvidia/base/ubuntu:24.04"; - openshell_podman_userns_auto_config = "suites/drivers/podman/fixtures/userns-auto.toml"; - openshell_podman_userns_keep_id_config = "suites/drivers/podman/fixtures/userns-keep-id.toml"; - openshell_podman_userns_private_config = "suites/drivers/podman/fixtures/userns-private.toml"; }; } ]; diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml deleted file mode 100644 index 1c6b001096..0000000000 --- a/tests/suites/conformance/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[workspace] -resolver = "2" -members = ["cli"] diff --git a/tests/suites/conformance/cli/Cargo.toml b/tests/suites/conformance/cli/Cargo.toml deleted file mode 100644 index 5edcefe687..0000000000 --- a/tests/suites/conformance/cli/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-test-conformance-cli" -version = "0.0.0" -edition = "2024" -rust-version = "1.94" -license = "Apache-2.0" -repository = "https://github.com/NVIDIA/OpenShell" - -[dependencies] -openshell-conformance = { path = "../../../../crates/openshell-conformance" } -tokio = { version = "1.43", features = ["macros", "rt"] } diff --git a/tests/suites/conformance/cli/tests/file_transfer.rs b/tests/suites/conformance/cli/tests/file_transfer.rs deleted file mode 100644 index 167506b87b..0000000000 --- a/tests/suites/conformance/cli/tests/file_transfer.rs +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver-agnostic sandbox file-transfer conformance tests. - -use openshell_conformance::{ - FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, OpenShellRunner, Scenario, -}; - -/// Exercise file and directory round trips through the candidate CLI. -#[tokio::test] -async fn round_trip() { - run(FILE_TRANSFER_ROUND_TRIP_SCENARIO).await; -} - -/// Exercise Git-aware upload filtering through the candidate CLI. -#[tokio::test] -async fn git_filtering() { - run(FILE_TRANSFER_GIT_FILTERING_SCENARIO).await; -} - -/// Exercise workspace boundary and filename safety through the candidate CLI. -#[tokio::test] -async fn path_safety() { - run(FILE_TRANSFER_PATH_SAFETY_SCENARIO).await; -} - -async fn run(scenario: Scenario) { - let mut runner = OpenShellRunner::from_env(scenario.name) - .expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - scenario.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("{} conformance scenario failed:\n{error}", scenario.name); - } -} diff --git a/tests/suites/conformance/cli/tests/lifecycle.rs b/tests/suites/conformance/cli/tests/lifecycle.rs deleted file mode 100644 index 1a18adef86..0000000000 --- a/tests/suites/conformance/cli/tests/lifecycle.rs +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver-agnostic sandbox lifecycle conformance tests. - -use openshell_conformance::{OpenShellRunner, SANDBOX_LIFECYCLE_SCENARIO}; - -/// Exercise stop, start, and stopped-deletion behavior through the candidate CLI. -#[tokio::test] -async fn sandbox_lifecycle() { - let mut runner = OpenShellRunner::from_env(SANDBOX_LIFECYCLE_SCENARIO.name) - .expect("candidate openshell CLI is available"); - - let result = async { - runner.check_gateway_status().await?; - SANDBOX_LIFECYCLE_SCENARIO.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("sandbox lifecycle conformance scenario failed:\n{error}"); - } -} diff --git a/tests/suites/conformance/cli/tests/policy_advisor.rs b/tests/suites/conformance/cli/tests/policy_advisor.rs deleted file mode 100644 index 7da2186dd7..0000000000 --- a/tests/suites/conformance/cli/tests/policy_advisor.rs +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Installed-artifact policy advisor conformance. - -use openshell_conformance::{ - MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, OpenShellRunner, - POLICY_LOCAL_SCENARIO, Scenario, -}; - -async fn run(scenario: &'static Scenario) { - let mut runner = - OpenShellRunner::from_env(scenario.name).expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - scenario.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("{} conformance scenario failed:\n{error}", scenario.name); - } -} - -#[tokio::test] -async fn mechanistic_proposal() { - run(&MECHANISTIC_PROPOSAL_SCENARIO).await; -} - -#[tokio::test] -async fn new_hostname_proposal() { - run(&NEW_HOSTNAME_PROPOSAL_SCENARIO).await; -} - -#[tokio::test] -async fn policy_local() { - run(&POLICY_LOCAL_SCENARIO).await; -} diff --git a/tests/suites/conformance/cli/tests/smoke.rs b/tests/suites/conformance/cli/tests/smoke.rs deleted file mode 100644 index 55306e852f..0000000000 --- a/tests/suites/conformance/cli/tests/smoke.rs +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver-agnostic `OpenShell` CLI conformance tests. - -use openshell_conformance::{OpenShellRunner, SMOKE_SCENARIO}; - -/// Exercise the public CLI against a provisioned `OpenShell` gateway. -/// -/// The test runner supplies the candidate CLI explicitly so the same archive -/// can validate artifacts installed into any supported test guest. -#[tokio::test] -async fn smoke() { - let mut runner = OpenShellRunner::from_env(SMOKE_SCENARIO.name) - .expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - SMOKE_SCENARIO.run(&mut runner).await - } - .await; - if let Err(error) = runner.finish(result).await { - panic!("conformance smoke scenario failed:\n{error}"); - } -} diff --git a/tests/suites/drivers/podman/fixtures/userns-auto.toml b/tests/suites/drivers/podman/fixtures/userns-auto.toml deleted file mode 100644 index 3701222828..0000000000 --- a/tests/suites/drivers/podman/fixtures/userns-auto.toml +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -userns = "auto" diff --git a/tests/suites/drivers/podman/fixtures/userns-keep-id.toml b/tests/suites/drivers/podman/fixtures/userns-keep-id.toml deleted file mode 100644 index c52cdb528f..0000000000 --- a/tests/suites/drivers/podman/fixtures/userns-keep-id.toml +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -userns = "keep-id" diff --git a/tests/suites/drivers/podman/fixtures/userns-private.toml b/tests/suites/drivers/podman/fixtures/userns-private.toml deleted file mode 100644 index 48a7968d13..0000000000 --- a/tests/suites/drivers/podman/fixtures/userns-private.toml +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -userns = "private" -uidmap = ["0:0:1", "1:1:65535"] -gidmap = ["0:0:1", "1:1:65535"] diff --git a/tests/suites/drivers/podman/tests/default_userns.rs b/tests/suites/drivers/podman/tests/default_userns.rs deleted file mode 100644 index 95cb8c814f..0000000000 --- a/tests/suites/drivers/podman/tests/default_userns.rs +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Podman-driver user-namespace integration tests. - -mod support; - -use openshell_conformance::OpenShellRunner; -use std::fs; -use std::path::PathBuf; -use std::time::Duration; - -use support::assert_podman_gateway; - -const SANDBOX_TIMEOUT: Duration = Duration::from_secs(300); -const PODMAN_TEST_INPUT_DIR_ENV: &str = "OPENSHELL_TEST_INPUT_DIR"; -const PODMAN_TEST_IMAGE_ENV: &str = "OPENSHELL_PODMAN_TEST_IMAGE"; - -const WORKSPACE_AND_UID_MAP_PROBE: &str = r#"set -eu -workload_owner="$(id -u):$(id -g)" -workspace_owner="$(stat -c '%u:%g' /sandbox)" -printf 'workload-owner=%s\nworkspace-owner=%s\n' "$workload_owner" "$workspace_owner" -test "$(id -u)" -ne 0 -test "$workspace_owner" = "$workload_owner" -probe=$(mktemp /sandbox/userns-probe.XXXXXX) -printf 'workspace probe\n' > "$probe" -rm "$probe" -echo podman-userns-workspace-ok -cat /proc/self/uid_map -"#; - -/// Verify that the gateway's user-namespace configuration matches Podman's -/// direct behavior for the same profile and preserves workspace access. -/// -/// The test runs a short-lived sandbox command and compares its user-namespace -/// mapping with the direct-Podman reference stored at -/// `OPENSHELL_TEST_INPUT_DIR/reference-uid-map`. The tmachine pre-test -/// playbook creates that reference in the same gateway-user context. This deliberately -/// avoids baking a particular Podman mapping into OpenShell's test contract. -/// The workload also verifies that the managed workspace is owned by its -/// non-root UID/GID and that it can create, write, and remove a file there. -#[tokio::test] -async fn configured_userns_matches_podman_reference() { - let mut runner = - OpenShellRunner::from_env("podman-userns").expect("candidate openshell CLI is available"); - let result = async { - runner.check_gateway_status().await?; - assert_podman_gateway(&runner).await?; - - let test_input_dir = std::env::var_os(PODMAN_TEST_INPUT_DIR_ENV) - .map(PathBuf::from) - .ok_or_else(|| format!("{PODMAN_TEST_INPUT_DIR_ENV} must name the Podman test-input directory"))?; - let expected_path = test_input_dir.join("reference-uid-map"); - let expected_uid_map = fs::read_to_string(&expected_path).map_err(|error| { - format!( - "could not read Podman reference UID map {}: {error}", - expected_path.display() - ) - })?; - let expected_uid_map = normalize_uid_map(&expected_uid_map).ok_or_else(|| { - format!( - "Podman reference UID map {} contains no mappings", - expected_path.display() - ) - })?; - - let workload_image = std::env::var(PODMAN_TEST_IMAGE_ENV) - .ok() - .filter(|image| !image.trim().is_empty()); - let sandbox_name = format!("pu-{}", runner.id()); - runner.track_sandbox(&sandbox_name); - let mut create_args = vec!["sandbox", "create", "--name", &sandbox_name]; - if let Some(image) = workload_image.as_deref() { - create_args.extend(["--from", image]); - } - create_args.extend(["--no-tty", "--", "sh", "-c", WORKSPACE_AND_UID_MAP_PROBE]); - let run = runner - .step("userns/workspace-and-uid-map") - .description("sandbox can write to its owned workspace and exposes its UID map") - .with_timeout(SANDBOX_TIMEOUT) - .run(&create_args) - .await - .map_err(|error| error.to_string())?; - run.require_success()?; - if !run.stdout().contains("podman-userns-workspace-ok") { - return Err(run.failure_diagnostic("non-root workload owns and can write to /sandbox")); - } - let sandbox_uid_map = normalize_uid_map(run.stdout()).ok_or_else(|| { - run.failure_diagnostic("sandbox returns a non-empty UID map") - })?; - if sandbox_uid_map != expected_uid_map { - return Err(format!( - "sandbox UID map differs from the direct Podman reference:\nexpected:\n{expected_uid_map}\nactual:\n{sandbox_uid_map}" - )); - } - Ok(()) - } - .await; - - if let Err(error) = runner.finish(result).await { - panic!("Podman userns test failed:\n{error}"); - } -} - -fn normalize_uid_map(value: &str) -> Option { - let mut mappings: Vec<(u64, u64, u64)> = Vec::new(); - for line in value.lines() { - let fields = line - .split_whitespace() - .map(str::parse::) - .collect::, _>>(); - let Ok(fields) = fields else { continue }; - let [inside, outside, length] = fields.as_slice() else { - continue; - }; - if let Some(previous) = mappings.last_mut() - && previous.0.checked_add(previous.2) == Some(*inside) - && previous.1.checked_add(previous.2) == Some(*outside) - { - previous.2 += *length; - } else { - mappings.push((*inside, *outside, *length)); - } - } - (!mappings.is_empty()).then(|| { - mappings - .iter() - .map(|(inside, outside, length)| format!("{inside} {outside} {length}")) - .collect::>() - .join("\n") - }) -} - -#[test] -fn adjacent_uid_ranges_match_a_combined_mapping() { - assert_eq!( - normalize_uid_map("0 0 1\n1 1 65535\n"), - normalize_uid_map("0 0 65536\n") - ); -}