From fa408350744957ff0d29632bfecaf426aaa2bc82 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 23 Sep 2026 22:00:14 +0200 Subject: [PATCH 1/3] test(cli): migrate gateway-free smoke coverage Signed-off-by: Evan Lezar --- .../tests/cli_help_integration.rs | 179 +++++++ .../tests/cloudflare_gateway_integration.rs | 95 ++++ crates/openshell-cli/tests/common/mod.rs | 40 ++ .../tests/gateway_registration_integration.rs | 257 ++++++++++ e2e/rust/tests/cf_auth_smoke.rs | 387 --------------- e2e/rust/tests/cli_smoke.rs | 454 ------------------ 6 files changed, 571 insertions(+), 841 deletions(-) create mode 100644 crates/openshell-cli/tests/cli_help_integration.rs create mode 100644 crates/openshell-cli/tests/cloudflare_gateway_integration.rs create mode 100644 crates/openshell-cli/tests/common/mod.rs create mode 100644 crates/openshell-cli/tests/gateway_registration_integration.rs delete mode 100644 e2e/rust/tests/cf_auth_smoke.rs delete mode 100644 e2e/rust/tests/cli_smoke.rs diff --git a/crates/openshell-cli/tests/cli_help_integration.rs b/crates/openshell-cli/tests/cli_help_integration.rs new file mode 100644 index 0000000000..fccb7fd4ae --- /dev/null +++ b/crates/openshell-cli/tests/cli_help_integration.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Rendered help and command-shape checks for the `openshell` binary. + +mod common; + +use common::run_isolated; + +#[test] +fn root_help_shows_top_level_commands() { + let output = run_isolated(&["--help"]); + assert_eq!(output.code, 0, "openshell --help:\n{}", output.combined); + + for command in ["gateway", "status", "sandbox", "forward", "logs", "policy"] { + assert!( + output.combined.contains(command), + "expected '{command}' in openshell --help:\n{}", + output.combined + ); + } +} + +#[test] +fn gateway_help_shows_registration_commands_and_omits_lifecycle_commands() { + let output = run_isolated(&["gateway", "--help"]); + assert_eq!(output.code, 0, "gateway --help:\n{}", output.combined); + + for command in ["add", "remove", "login", "logout", "select", "info", "list"] { + assert!( + output.combined.contains(command), + "expected '{command}' in gateway --help:\n{}", + output.combined + ); + } + for removed in ["start", "stop", "destroy"] { + assert!( + !output.combined.contains(removed), + "unexpected removed command '{removed}' in gateway --help:\n{}", + output.combined + ); + } +} + +#[test] +fn sandbox_help_shows_transfer_and_lifecycle_commands() { + let output = run_isolated(&["sandbox", "--help"]); + assert_eq!(output.code, 0, "sandbox --help:\n{}", output.combined); + + for command in [ + "upload", "download", "create", "get", "list", "delete", "connect", + ] { + assert!( + output.combined.contains(command), + "expected '{command}' in sandbox --help:\n{}", + output.combined + ); + } +} + +#[test] +fn sandbox_create_help_shows_creation_flags() { + let output = run_isolated(&["sandbox", "create", "--help"]); + assert_eq!( + output.code, 0, + "sandbox create --help:\n{}", + output.combined + ); + + for flag in [ + "--gpu", + "--upload", + "--no-git-ignore", + "--editor", + "--auto-providers", + "--no-auto-providers", + ] { + assert!( + output.combined.contains(flag), + "expected '{flag}' in sandbox create --help:\n{}", + output.combined + ); + } +} + +#[test] +fn sandbox_connect_help_shows_editor_flag() { + let output = run_isolated(&["sandbox", "connect", "--help"]); + assert_eq!( + output.code, 0, + "sandbox connect --help:\n{}", + output.combined + ); + assert!(output.combined.contains("--editor"), "{}", output.combined); +} + +#[test] +fn gateway_add_help_shows_endpoint_and_gateway_type_flags() { + let output = run_isolated(&["gateway", "add", "--help"]); + assert_eq!(output.code, 0, "gateway add --help:\n{}", output.combined); + + for expected in ["--name", "--remote", "--local"] { + assert!( + output.combined.contains(expected), + "expected '{expected}' in gateway add --help:\n{}", + output.combined + ); + } + assert!( + output.combined.contains("endpoint") || output.combined.contains(""), + "expected endpoint argument in gateway add --help:\n{}", + output.combined + ); +} + +#[test] +fn gateway_login_help_describes_authentication() { + let output = run_isolated(&["gateway", "login", "--help"]); + assert_eq!(output.code, 0, "gateway login --help:\n{}", output.combined); + + let help = output.combined.to_lowercase(); + assert!( + ["authenticat", "cloudflare", "login", "browser"] + .iter() + .any(|term| help.contains(term)), + "expected auth-related gateway login help:\n{}", + output.combined + ); +} + +#[test] +fn removed_gateway_lifecycle_subcommands_fail_to_parse() { + for command in ["start", "stop", "destroy"] { + let output = run_isolated(&["gateway", command, "--help"]); + assert_ne!( + output.code, 0, + "gateway {command} should fail after lifecycle command removal" + ); + assert!( + output.combined.contains("unrecognized subcommand") + || output.combined.contains("error:"), + "expected parser error for gateway {command}:\n{}", + output.combined + ); + } +} + +#[test] +fn gateway_add_rejects_conflicting_type_flags() { + let conflicting = run_isolated(&[ + "gateway", + "add", + "https://example.com", + "--remote", + "user@host", + "--local", + ]); + assert_ne!( + conflicting.code, 0, + "--remote and --local should conflict:\n{}", + conflicting.combined + ); +} + +#[test] +fn gateway_add_rejects_removed_ssh_key_flag() { + let removed = run_isolated(&[ + "gateway", + "add", + "https://example.com", + "--ssh-key", + "/tmp/fake-key", + ]); + assert_ne!( + removed.code, 0, + "removed --ssh-key flag should fail:\n{}", + removed.combined + ); +} diff --git a/crates/openshell-cli/tests/cloudflare_gateway_integration.rs b/crates/openshell-cli/tests/cloudflare_gateway_integration.rs new file mode 100644 index 0000000000..6b349aef17 --- /dev/null +++ b/crates/openshell-cli/tests/cloudflare_gateway_integration.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cloudflare gateway registration behavior that needs no external network. + +mod common; + +use common::{run, run_isolated}; + +#[test] +fn gateway_add_creates_cloudflare_metadata_and_selects_gateway() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + + let output = run( + config_dir.path(), + system_dir.path(), + &[ + "gateway", + "add", + "https://my-gateway.example.com", + "--name", + "test-cf-gw", + ], + ); + assert_eq!(output.code, 0, "gateway add:\n{}", output.combined); + + let metadata_path = config_dir + .path() + .join("openshell/gateways/test-cf-gw/metadata.json"); + let metadata: serde_json::Value = serde_json::from_slice( + &std::fs::read(&metadata_path).expect("read Cloudflare gateway metadata"), + ) + .expect("parse Cloudflare gateway metadata"); + assert_eq!(metadata["auth_mode"], "cloudflare_jwt"); + assert_eq!( + metadata["gateway_endpoint"], + "https://my-gateway.example.com" + ); + assert_eq!(metadata["name"], "test-cf-gw"); + assert_eq!(metadata["is_remote"], true); + + let active = std::fs::read_to_string(config_dir.path().join("openshell/active_gateway")) + .expect("read active gateway"); + assert_eq!(active.trim(), "test-cf-gw"); + assert!( + output.combined.contains("test-cf-gw") && output.combined.contains("added"), + "{}", + output.combined + ); +} + +#[test] +fn gateway_add_derives_cloudflare_name_from_hostname() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + + let output = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "https://my-special-gateway.brevlab.com"], + ); + assert_eq!(output.code, 0, "gateway add:\n{}", output.combined); + assert!( + config_dir + .path() + .join("openshell/gateways/my-special-gateway.brevlab.com/metadata.json") + .exists() + ); +} + +#[test] +fn ssh_gateway_shorthand_conflicts_with_local_type() { + let local = run_isolated(&["gateway", "add", "ssh://user@host:8080", "--local"]); + assert_ne!(local.code, 0, "ssh:// with --local should fail"); +} + +#[test] +fn ssh_gateway_shorthand_conflicts_with_explicit_remote() { + let remote = run_isolated(&[ + "gateway", + "add", + "ssh://user@host:8080", + "--remote", + "user@host", + ]); + assert_ne!(remote.code, 0, "ssh:// with --remote should fail"); +} + +#[test] +fn ssh_gateway_shorthand_requires_port() { + let output = run_isolated(&["gateway", "add", "ssh://user@host"]); + assert_ne!(output.code, 0, "ssh:// without port should fail"); + assert!(output.combined.contains("port"), "{}", output.combined); +} diff --git a/crates/openshell-cli/tests/common/mod.rs b/crates/openshell-cli/tests/common/mod.rs new file mode 100644 index 0000000000..b86741d8b6 --- /dev/null +++ b/crates/openshell-cli/tests/common/mod.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; +use std::process::{Command, Stdio}; + +pub struct CliOutput { + #[allow(dead_code)] + pub stdout: String, + pub combined: String, + pub code: i32, +} + +pub fn run(config_dir: &Path, system_dir: &Path, args: &[&str]) -> CliOutput { + let output = Command::new(env!("CARGO_BIN_EXE_openshell")) + .args(args) + .env("XDG_CONFIG_HOME", config_dir) + .env("HOME", config_dir) + .env("OPENSHELL_SYSTEM_GATEWAY_DIR", system_dir) + .env("OPENSHELL_NO_BROWSER", "1") + .env_remove("OPENSHELL_GATEWAY") + .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .stdin(Stdio::null()) + .output() + .expect("run openshell"); + + let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8"); + let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8"); + CliOutput { + combined: format!("{stdout}{stderr}"), + stdout, + code: output.status.code().unwrap_or(-1), + } +} + +pub fn run_isolated(args: &[&str]) -> CliOutput { + let config_dir = tempfile::tempdir().expect("create isolated user config dir"); + let system_dir = tempfile::tempdir().expect("create isolated system config dir"); + run(config_dir.path(), system_dir.path(), args) +} diff --git a/crates/openshell-cli/tests/gateway_registration_integration.rs b/crates/openshell-cli/tests/gateway_registration_integration.rs new file mode 100644 index 0000000000..28688a4ea5 --- /dev/null +++ b/crates/openshell-cli/tests/gateway_registration_integration.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway registration and configuration behavior that needs no live gateway. + +mod common; + +use std::path::Path; + +use common::{run, run_isolated}; + +fn write_gateway_metadata( + root: &Path, + name: &str, + endpoint: &str, + gateway_port: u16, + is_remote: bool, + auth_mode: &str, +) { + let gateway_dir = root.join("gateways").join(name); + std::fs::create_dir_all(&gateway_dir).expect("create gateway dir"); + let metadata = serde_json::json!({ + "name": name, + "gateway_endpoint": endpoint, + "gateway_port": gateway_port, + "is_remote": is_remote, + "auth_mode": auth_mode, + }); + std::fs::write( + gateway_dir.join("metadata.json"), + serde_json::to_vec_pretty(&metadata).expect("serialize gateway metadata"), + ) + .expect("write gateway metadata"); +} + +fn write_user_gateway_metadata( + config_dir: &Path, + name: &str, + endpoint: &str, + gateway_port: u16, + is_remote: bool, + auth_mode: &str, +) { + write_gateway_metadata( + &config_dir.join("openshell"), + name, + endpoint, + gateway_port, + is_remote, + auth_mode, + ); +} + +fn write_active_gateway(config_dir: &Path, name: &str) { + let active_path = config_dir.join("openshell").join("active_gateway"); + std::fs::create_dir_all(active_path.parent().expect("active gateway parent")) + .expect("create active gateway parent"); + std::fs::write(active_path, format!("{name}\n")).expect("write active gateway"); +} + +fn seed_gateway_sources(config_dir: &Path, system_dir: &Path) { + write_user_gateway_metadata( + config_dir, + "alpha", + "https://alpha.example.com", + 443, + true, + "cloudflare_jwt", + ); + write_gateway_metadata( + system_dir, + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); +} + +#[test] +fn status_without_gateway_prints_registration_hint() { + let output = run_isolated(&["status"]); + assert_eq!( + output.code, 0, + "status without a gateway should succeed:\n{}", + output.combined + ); + assert!(output.combined.contains("No gateway configured")); + assert!( + output.combined.contains("openshell gateway add "), + "{}", + output.combined + ); +} + +#[test] +fn gateway_list_table_shows_user_and_system_sources() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + seed_gateway_sources(config_dir.path(), system_dir.path()); + write_active_gateway(config_dir.path(), "alpha"); + + let output = run(config_dir.path(), system_dir.path(), &["gateway", "list"]); + assert_eq!(output.code, 0, "gateway list:\n{}", output.combined); + assert!(output.combined.contains("SOURCE"), "{}", output.combined); + + let alpha = output + .combined + .lines() + .find(|line| line.contains("alpha")) + .expect("find alpha row"); + assert!(alpha.contains("user"), "{}", output.combined); + + let beta = output + .combined + .lines() + .find(|line| line.contains("beta")) + .expect("find beta row"); + assert!(beta.contains("system"), "{}", output.combined); +} + +#[test] +fn gateway_list_json_includes_user_and_system_sources() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + seed_gateway_sources(config_dir.path(), system_dir.path()); + + let output = run( + config_dir.path(), + system_dir.path(), + &["gateway", "list", "-o", "json"], + ); + assert_eq!(output.code, 0, "gateway list -o json:\n{}", output.combined); + + let items: serde_json::Value = + serde_json::from_str(&output.stdout).expect("parse gateway list JSON"); + let items = items.as_array().expect("gateway list JSON array"); + assert_eq!(items.len(), 2); + assert_eq!( + items.iter().find(|item| item["name"] == "alpha").unwrap()["source"], + "user" + ); + assert_eq!( + items.iter().find(|item| item["name"] == "beta").unwrap()["source"], + "system" + ); +} + +#[test] +fn user_registration_can_shadow_system_gateway() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + write_gateway_metadata( + system_dir.path(), + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); + + let added = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "http://127.0.0.1:17671", "--name", "beta"], + ); + assert_eq!(added.code, 0, "gateway add:\n{}", added.combined); + + let listed = run( + config_dir.path(), + system_dir.path(), + &["gateway", "list", "-o", "json"], + ); + let items: serde_json::Value = + serde_json::from_str(&listed.stdout).expect("parse gateway list JSON"); + let beta = items + .as_array() + .unwrap() + .iter() + .find(|item| item["name"] == "beta") + .unwrap(); + assert_eq!(beta["source"], "user"); + assert_eq!(beta["endpoint"], "http://127.0.0.1:17671"); +} + +#[test] +fn gateway_remove_rejects_system_registration_and_preserves_it() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + write_gateway_metadata( + system_dir.path(), + "beta", + "http://127.0.0.1:17670", + 17670, + false, + "plaintext", + ); + + let removed = run( + config_dir.path(), + system_dir.path(), + &["gateway", "remove", "beta"], + ); + assert_ne!(removed.code, 0, "system gateway removal should fail"); + let normalized = removed + .combined + .replace(['│', '×'], " ") + .split_whitespace() + .collect::>() + .join(" "); + assert!( + normalized.contains("installed by the system and cannot be removed from user config"), + "{}", + removed.combined + ); + + let listed = run( + config_dir.path(), + system_dir.path(), + &["gateway", "list", "-o", "json"], + ); + let items: serde_json::Value = + serde_json::from_str(&listed.stdout).expect("parse gateway list JSON"); + let beta = items + .as_array() + .unwrap() + .iter() + .find(|item| item["name"] == "beta") + .unwrap(); + assert_eq!(beta["source"], "system"); + assert_eq!(beta["endpoint"], "http://127.0.0.1:17670"); +} + +#[test] +fn gateway_add_rejects_duplicate_user_name() { + let config_dir = tempfile::tempdir().expect("create user config dir"); + let system_dir = tempfile::tempdir().expect("create system config dir"); + + let first = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "http://127.0.0.1:1", "--name", "my-gw"], + ); + assert_eq!(first.code, 0, "first gateway add:\n{}", first.combined); + + let duplicate = run( + config_dir.path(), + system_dir.path(), + &["gateway", "add", "http://127.0.0.1:2", "--name", "my-gw"], + ); + assert_ne!(duplicate.code, 0, "duplicate gateway add should fail"); + assert!( + duplicate.combined.contains("already exists"), + "{}", + duplicate.combined + ); +} diff --git a/e2e/rust/tests/cf_auth_smoke.rs b/e2e/rust/tests/cf_auth_smoke.rs deleted file mode 100644 index 34fa1be02c..0000000000 --- a/e2e/rust/tests/cf_auth_smoke.rs +++ /dev/null @@ -1,387 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! CLI smoke tests for Cloudflare tunnel auth commands. -//! -//! These tests do NOT require a running gateway — they exercise the CLI binary -//! directly, validating that the new Cloudflare-related commands and flags -//! parse correctly and behave as expected. - -use std::process::Stdio; - -use openshell_e2e::harness::binary::openshell_cmd; -use openshell_e2e::harness::output::strip_ansi; - -/// Run `openshell ` with an isolated (empty) config directory so it -/// cannot discover any real gateway. Returns (combined stdout+stderr, exit code). -async fn run_isolated(args: &[&str]) -> (String, i32) { - let tmpdir = tempfile::tempdir().expect("create isolated config dir"); - let mut cmd = openshell_cmd(); - cmd.args(args) - .env("XDG_CONFIG_HOME", tmpdir.path()) - .env("HOME", tmpdir.path()) - .env_remove("OPENSHELL_GATEWAY") - .env_remove("OPENSHELL_GATEWAY_ENDPOINT") - // Suppress browser popup during auth flow. - .env("OPENSHELL_NO_BROWSER", "1") - // Use a closed stdin so auth prompts don't hang the test. - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let output = cmd.output().await.expect("spawn openshell"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - let code = output.status.code().unwrap_or(-1); - (combined, code) -} - -/// Run `openshell ` with a given tmpdir as config (for persisting state -/// across multiple commands). Returns (combined stdout+stderr, exit code). -async fn run_with_config(tmpdir: &std::path::Path, args: &[&str]) -> (String, i32) { - let mut cmd = openshell_cmd(); - cmd.args(args) - .env("XDG_CONFIG_HOME", tmpdir) - .env("HOME", tmpdir) - .env_remove("OPENSHELL_GATEWAY") - .env_remove("OPENSHELL_GATEWAY_ENDPOINT") - // Suppress browser popup during auth flow. - .env("OPENSHELL_NO_BROWSER", "1") - // Use a closed stdin so auth prompts don't hang the test. - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let output = cmd.output().await.expect("spawn openshell"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - let code = output.status.code().unwrap_or(-1); - (combined, code) -} - -// ------------------------------------------------------------------- -// Test 8: gateway lifecycle commands are not exposed through the CLI -// ------------------------------------------------------------------- - -/// `openshell gateway --help` must not show removed lifecycle commands. -#[tokio::test] -async fn gateway_help_omits_lifecycle_commands() { - let (output, code) = run_isolated(&["gateway", "--help"]).await; - assert_eq!(code, 0, "gateway --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - for removed in ["start", "stop", "destroy"] { - assert!( - !clean.contains(removed), - "did not expect removed gateway lifecycle command '{removed}' in gateway help:\n{clean}" - ); - } -} - -// ------------------------------------------------------------------- -// Test 9: `gateway add` and `gateway login` are recognized -// ------------------------------------------------------------------- - -/// `openshell gateway --help` must list `add` and `login` subcommands. -#[tokio::test] -async fn gateway_help_shows_add_and_login() { - let (output, code) = run_isolated(&["gateway", "--help"]).await; - assert_eq!(code, 0, "gateway --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("add"), - "expected 'add' in gateway --help output:\n{clean}" - ); - assert!( - clean.contains("login"), - "expected 'login' in gateway --help output:\n{clean}" - ); -} - -/// `openshell gateway add --help` must show the endpoint arg and gateway-type flags. -#[tokio::test] -async fn gateway_add_help_shows_flags() { - let (output, code) = run_isolated(&["gateway", "add", "--help"]).await; - assert_eq!(code, 0, "gateway add --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("--name"), - "expected '--name' in gateway add --help:\n{clean}" - ); - assert!( - clean.contains("--remote"), - "expected '--remote' in gateway add --help:\n{clean}" - ); - assert!( - clean.contains("--local"), - "expected '--local' in gateway add --help:\n{clean}" - ); - assert!( - // The positional argument for the endpoint - clean.contains("endpoint") || clean.contains(""), - "expected endpoint argument in gateway add --help:\n{clean}" - ); -} - -/// `openshell gateway login --help` is recognized. -#[tokio::test] -async fn gateway_login_help_is_recognized() { - let (output, code) = run_isolated(&["gateway", "login", "--help"]).await; - assert_eq!(code, 0, "gateway login --help should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - // Should mention authenticating or Cloudflare - assert!( - clean.to_lowercase().contains("authenticat") - || clean.to_lowercase().contains("cloudflare") - || clean.to_lowercase().contains("login") - || clean.to_lowercase().contains("browser"), - "expected auth-related text in gateway login --help:\n{clean}" - ); -} - -// ------------------------------------------------------------------- -// Test 10: `gateway add` creates metadata with cloudflare_jwt -// ------------------------------------------------------------------- - -/// `openshell gateway add ` (cloud gateway) should: -/// - Create cluster metadata with `auth_mode` = `"cloudflare_jwt"` -/// - Set the gateway as active -/// - Attempt browser authentication (which will fail in CI — non-fatal) -#[tokio::test] -async fn gateway_add_creates_cf_metadata() { - let tmpdir = tempfile::tempdir().expect("create config dir"); - - let (output, code) = run_with_config( - tmpdir.path(), - &[ - "gateway", - "add", - "https://my-gateway.example.com", - "--name", - "test-cf-gw", - ], - ) - .await; - - assert_eq!( - code, 0, - "gateway add should exit 0 (auth failure is non-fatal):\n{output}" - ); - - // Verify the metadata file was written. - let metadata_path = tmpdir - .path() - .join("openshell") - .join("gateways") - .join("test-cf-gw") - .join("metadata.json"); - assert!( - metadata_path.exists(), - "metadata file should exist at {}", - metadata_path.display() - ); - - let metadata_content = std::fs::read_to_string(&metadata_path).expect("read metadata"); - let metadata: serde_json::Value = - serde_json::from_str(&metadata_content).expect("parse metadata JSON"); - - assert_eq!( - metadata["auth_mode"].as_str(), - Some("cloudflare_jwt"), - "auth_mode should be 'cloudflare_jwt', got: {metadata_content}" - ); - assert_eq!( - metadata["gateway_endpoint"].as_str(), - Some("https://my-gateway.example.com"), - "gateway_endpoint should match the provided URL" - ); - assert_eq!( - metadata["name"].as_str(), - Some("test-cf-gw"), - "name should match --name flag" - ); - assert_eq!( - metadata["is_remote"].as_bool(), - Some(true), - "CF gateway should be marked as remote" - ); - - // Verify the gateway was set as active. - let active_path = tmpdir.path().join("openshell").join("active_gateway"); - assert!( - active_path.exists(), - "active_gateway file should exist at {}", - active_path.display() - ); - let active = std::fs::read_to_string(&active_path).expect("read active_gateway"); - assert_eq!( - active.trim(), - "test-cf-gw", - "active gateway should be 'test-cf-gw'" - ); - - // Verify the output mentions the gateway was added. - let clean = strip_ansi(&output); - assert!( - clean.contains("test-cf-gw") && clean.contains("added"), - "output should confirm gateway was added:\n{clean}" - ); -} - -/// `gateway add` without `--name` should derive a name from the hostname. -#[tokio::test] -async fn gateway_add_derives_name_from_hostname() { - let tmpdir = tempfile::tempdir().expect("create config dir"); - - let (output, code) = run_with_config( - tmpdir.path(), - &["gateway", "add", "https://my-special-gateway.brevlab.com"], - ) - .await; - - assert_eq!(code, 0, "gateway add should exit 0:\n{output}"); - - // The derived name should be the hostname. - let metadata_path = tmpdir - .path() - .join("openshell") - .join("gateways") - .join("my-special-gateway.brevlab.com") - .join("metadata.json"); - assert!( - metadata_path.exists(), - "metadata file should exist with hostname-derived name at {}", - metadata_path.display() - ); -} - -// ------------------------------------------------------------------- -// Test 11: `gateway add` flag constraints -// ------------------------------------------------------------------- - -/// `--remote` and `--local` are mutually exclusive. -#[tokio::test] -async fn gateway_add_remote_and_local_conflict() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "https://example.com", - "--remote", - "user@host", - "--local", - ]) - .await; - - assert_ne!( - code, 0, - "--remote and --local together should fail:\n{output}" - ); -} - -/// `--ssh-key` was removed from `gateway add`. -#[tokio::test] -async fn gateway_add_rejects_removed_ssh_key_flag() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "https://example.com", - "--ssh-key", - "/tmp/fake-key", - ]) - .await; - - assert_ne!( - code, 0, - "--ssh-key should fail after gateway lifecycle bootstrap removal:\n{output}" - ); -} - -// ------------------------------------------------------------------- -// Test 12: `gateway add` rejects duplicate names -// ------------------------------------------------------------------- - -/// Adding a gateway with a name that already exists should fail. -#[tokio::test] -async fn gateway_add_rejects_duplicate_name() { - let tmpdir = tempfile::tempdir().expect("create config dir"); - - // First add should succeed. - let (output, code) = run_with_config( - tmpdir.path(), - &[ - "gateway", - "add", - "https://first.example.com", - "--name", - "my-gw", - ], - ) - .await; - assert_eq!(code, 0, "first gateway add should succeed:\n{output}"); - - // Second add with the same name should fail. - let (output, code) = run_with_config( - tmpdir.path(), - &[ - "gateway", - "add", - "https://second.example.com", - "--name", - "my-gw", - ], - ) - .await; - assert_ne!(code, 0, "duplicate gateway add should fail:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("already exists"), - "error should mention 'already exists':\n{clean}" - ); -} - -// ------------------------------------------------------------------- -// Test 13: `gateway add ssh://` shorthand constraints -// ------------------------------------------------------------------- - -/// `ssh://` endpoint with `--local` should fail. -#[tokio::test] -async fn gateway_add_ssh_url_conflicts_with_local() { - let (output, code) = run_isolated(&["gateway", "add", "ssh://user@host:8080", "--local"]).await; - - assert_ne!(code, 0, "ssh:// with --local should fail:\n{output}"); -} - -/// `ssh://` endpoint with `--remote` should fail (redundant). -#[tokio::test] -async fn gateway_add_ssh_url_conflicts_with_remote() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "ssh://user@host:8080", - "--remote", - "user@host", - ]) - .await; - - assert_ne!(code, 0, "ssh:// with --remote should fail:\n{output}"); -} - -/// `ssh://` endpoint without a port should fail. -#[tokio::test] -async fn gateway_add_ssh_url_requires_port() { - let (output, code) = run_isolated(&["gateway", "add", "ssh://user@host"]).await; - - assert_ne!(code, 0, "ssh:// without port should fail:\n{output}"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("port"), - "error should mention port:\n{clean}" - ); -} diff --git a/e2e/rust/tests/cli_smoke.rs b/e2e/rust/tests/cli_smoke.rs deleted file mode 100644 index fe3d78b146..0000000000 --- a/e2e/rust/tests/cli_smoke.rs +++ /dev/null @@ -1,454 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! CLI smoke tests that verify command structure and graceful error handling. -//! -//! These tests do NOT require a running gateway — they exercise the CLI binary -//! directly, validating that the restructured command tree parses correctly and -//! handles edge cases like missing gateway configuration. - -use std::fs; -use std::path::Path; -use std::process::Stdio; - -use openshell_e2e::harness::binary::openshell_cmd; -use openshell_e2e::harness::output::strip_ansi; - -async fn run_with_config( - config_dir: &Path, - system_dir: Option<&Path>, - args: &[&str], -) -> (String, i32) { - let mut cmd = openshell_cmd(); - cmd.args(args) - .env("XDG_CONFIG_HOME", config_dir) - .env("HOME", config_dir) - .env_remove("OPENSHELL_GATEWAY") - .env_remove("OPENSHELL_GATEWAY_ENDPOINT") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - if let Some(system_dir) = system_dir { - cmd.env("OPENSHELL_SYSTEM_GATEWAY_DIR", system_dir); - } else { - cmd.env_remove("OPENSHELL_SYSTEM_GATEWAY_DIR"); - } - - let output = cmd.output().await.expect("spawn openshell"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - let code = output.status.code().unwrap_or(-1); - (combined, code) -} - -/// Run `openshell ` with an isolated (empty) config directory so it -/// cannot discover any real gateway. -async fn run_isolated(args: &[&str]) -> (String, i32) { - let tmpdir = tempfile::tempdir().expect("create isolated config dir"); - let system_dir = tempfile::tempdir().expect("create isolated system config dir"); - run_with_config(tmpdir.path(), Some(system_dir.path()), args).await -} - -fn write_gateway_metadata( - root: &Path, - name: &str, - endpoint: &str, - gateway_port: u16, - is_remote: bool, - auth_mode: &str, -) { - let gateway_dir = root.join("gateways").join(name); - fs::create_dir_all(&gateway_dir).expect("create gateway dir"); - let metadata = serde_json::json!({ - "name": name, - "gateway_endpoint": endpoint, - "gateway_port": gateway_port, - "is_remote": is_remote, - "auth_mode": auth_mode, - }); - fs::write( - gateway_dir.join("metadata.json"), - serde_json::to_vec_pretty(&metadata).expect("serialize gateway metadata"), - ) - .expect("write gateway metadata"); -} - -fn write_user_gateway_metadata( - config_dir: &Path, - name: &str, - endpoint: &str, - gateway_port: u16, - is_remote: bool, - auth_mode: &str, -) { - write_gateway_metadata( - &config_dir.join("openshell"), - name, - endpoint, - gateway_port, - is_remote, - auth_mode, - ); -} - -fn write_system_gateway_metadata( - system_dir: &Path, - name: &str, - endpoint: &str, - gateway_port: u16, - is_remote: bool, - auth_mode: &str, -) { - write_gateway_metadata( - system_dir, - name, - endpoint, - gateway_port, - is_remote, - auth_mode, - ); -} - -fn write_active_gateway(config_dir: &Path, name: &str) { - let active_path = config_dir.join("openshell").join("active_gateway"); - fs::create_dir_all(active_path.parent().expect("active gateway parent")) - .expect("create active gateway parent"); - fs::write(active_path, format!("{name}\n")).expect("write active gateway"); -} - -fn seed_gateway_sources(config_dir: &Path, system_dir: &Path) { - write_user_gateway_metadata( - config_dir, - "alpha", - "https://alpha.example.com", - 443, - true, - "cloudflare_jwt", - ); - write_system_gateway_metadata( - system_dir, - "beta", - "http://127.0.0.1:17670", - 17670, - false, - "plaintext", - ); -} - -// ------------------------------------------------------------------- -// Top-level --help shows the restructured command tree -// ------------------------------------------------------------------- - -/// `openshell --help` must list the new top-level commands: gateway, status, -/// forward, logs, policy. -#[tokio::test] -async fn help_shows_restructured_commands() { - let (output, code) = run_isolated(&["--help"]).await; - assert_eq!(code, 0, "openshell --help should exit 0"); - - let clean = strip_ansi(&output); - for cmd in ["gateway", "status", "sandbox", "forward", "logs", "policy"] { - assert!( - clean.contains(cmd), - "expected '{cmd}' in --help output:\n{clean}" - ); - } -} - -/// `openshell gateway --help` must list registration/auth commands, not -/// service lifecycle commands. -#[tokio::test] -async fn gateway_help_shows_subcommands() { - let (output, code) = run_isolated(&["gateway", "--help"]).await; - assert_eq!(code, 0, "openshell gateway --help should exit 0"); - - let clean = strip_ansi(&output); - for sub in ["add", "remove", "login", "logout", "select", "info", "list"] { - assert!( - clean.contains(sub), - "expected '{sub}' in gateway --help output:\n{clean}" - ); - } - - for removed in ["start", "stop", "destroy"] { - assert!( - !clean.contains(removed), - "did not expect removed gateway lifecycle subcommand '{removed}' in help:\n{clean}" - ); - } -} - -/// `openshell sandbox --help` must list upload and download alongside create, -/// get, list, delete, connect. -#[tokio::test] -async fn sandbox_help_shows_upload_download() { - let (output, code) = run_isolated(&["sandbox", "--help"]).await; - assert_eq!(code, 0, "openshell sandbox --help should exit 0"); - - let clean = strip_ansi(&output); - for sub in [ - "upload", "download", "create", "get", "list", "delete", "connect", - ] { - assert!( - clean.contains(sub), - "expected '{sub}' in sandbox --help output:\n{clean}" - ); - } -} - -/// `openshell sandbox create --help` must show `--gpu`, `--upload`, -/// `--no-git-ignore`, `--editor`, and `--auto-providers`/`--no-auto-providers`. -#[tokio::test] -async fn sandbox_create_help_shows_new_flags() { - let (output, code) = run_isolated(&["sandbox", "create", "--help"]).await; - assert_eq!(code, 0, "openshell sandbox create --help should exit 0"); - - let clean = strip_ansi(&output); - for flag in [ - "--gpu", - "--upload", - "--no-git-ignore", - "--editor", - "--auto-providers", - "--no-auto-providers", - ] { - assert!( - clean.contains(flag), - "expected '{flag}' in sandbox create --help:\n{clean}" - ); - } -} - -/// `openshell sandbox connect --help` must show `--editor`. -#[tokio::test] -async fn sandbox_connect_help_shows_editor_flag() { - let (output, code) = run_isolated(&["sandbox", "connect", "--help"]).await; - assert_eq!(code, 0, "openshell sandbox connect --help should exit 0"); - - let clean = strip_ansi(&output); - assert!( - clean.contains("--editor"), - "expected '--editor' in sandbox connect --help:\n{clean}" - ); -} - -/// Removed gateway lifecycle subcommands should fail during parsing. -#[tokio::test] -async fn gateway_lifecycle_subcommands_are_removed() { - for subcommand in ["start", "stop", "destroy"] { - let (output, code) = run_isolated(&["gateway", subcommand, "--help"]).await; - assert!( - code != 0, - "openshell gateway {subcommand} should fail after lifecycle command removal" - ); - - let clean = strip_ansi(&output); - assert!( - clean.contains("unrecognized subcommand") || clean.contains("error:"), - "expected parser error for removed gateway subcommand '{subcommand}':\n{clean}" - ); - } -} - -// ------------------------------------------------------------------- -// Graceful handling: `openshell status` without a gateway -// ------------------------------------------------------------------- - -/// `openshell status` with no gateway configured should exit 0 and print a -/// friendly message instead of erroring. -#[tokio::test] -async fn status_without_gateway_prints_friendly_message() { - let (output, code) = run_isolated(&["status"]).await; - assert_eq!( - code, 0, - "openshell status should exit 0 even without a gateway, got output:\n{output}" - ); - - let clean = strip_ansi(&output); - assert!( - clean.contains("No gateway configured"), - "expected 'No gateway configured' in status output:\n{clean}" - ); - assert!( - clean.contains("openshell gateway add "), - "expected hint to register a gateway:\n{clean}" - ); -} - -// ------------------------------------------------------------------- -// Gateway list source indicators -// ------------------------------------------------------------------- - -#[tokio::test] -async fn gateway_list_table_shows_user_and_system_sources() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - seed_gateway_sources(config_dir.path(), system_dir.path()); - write_active_gateway(config_dir.path(), "alpha"); - - let (output, code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list"], - ) - .await; - assert_eq!(code, 0, "gateway list should exit 0:\n{output}"); - - let clean = strip_ansi(&output); - assert!(clean.contains("SOURCE"), "expected SOURCE column:\n{clean}"); - - let alpha_line = clean - .lines() - .find(|line| line.contains("alpha")) - .expect("find alpha row"); - assert!( - alpha_line.contains("user"), - "expected alpha row to show user source:\n{clean}" - ); - - let beta_line = clean - .lines() - .find(|line| line.contains("beta")) - .expect("find beta row"); - assert!( - beta_line.contains("system"), - "expected beta row to show system source:\n{clean}" - ); -} - -#[tokio::test] -async fn gateway_list_json_includes_user_and_system_sources() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - seed_gateway_sources(config_dir.path(), system_dir.path()); - - let (output, code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list", "-o", "json"], - ) - .await; - assert_eq!(code, 0, "gateway list -o json should exit 0:\n{output}"); - - let items: serde_json::Value = serde_json::from_str(&output).expect("parse gateway list json"); - let items = items.as_array().expect("gateway list json array"); - assert_eq!(items.len(), 2, "expected two gateways in json output"); - - let alpha = items - .iter() - .find(|item| item["name"] == "alpha") - .expect("find alpha entry"); - assert_eq!(alpha["source"], "user"); - - let beta = items - .iter() - .find(|item| item["name"] == "beta") - .expect("find beta entry"); - assert_eq!(beta["source"], "system"); -} - -#[tokio::test] -async fn gateway_add_can_shadow_system_gateway_with_user_registration() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - write_system_gateway_metadata( - system_dir.path(), - "beta", - "http://127.0.0.1:17670", - 17670, - false, - "plaintext", - ); - - let (add_output, add_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "add", "http://127.0.0.1:17671", "--name", "beta"], - ) - .await; - assert_eq!( - add_code, 0, - "gateway add should allow a user registration to shadow a system gateway:\n{add_output}" - ); - - let (list_output, list_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list", "-o", "json"], - ) - .await; - assert_eq!( - list_code, 0, - "gateway list -o json should exit 0:\n{list_output}" - ); - - let items: serde_json::Value = - serde_json::from_str(&list_output).expect("parse gateway list json"); - let beta = items - .as_array() - .expect("gateway list json array") - .iter() - .find(|item| item["name"] == "beta") - .expect("find beta entry"); - assert_eq!(beta["source"], "user"); - assert_eq!(beta["endpoint"], "http://127.0.0.1:17671"); -} - -#[tokio::test] -async fn gateway_remove_rejects_system_only_registration_and_preserves_entry() { - let config_dir = tempfile::tempdir().expect("create config dir"); - let system_dir = tempfile::tempdir().expect("create system dir"); - write_system_gateway_metadata( - system_dir.path(), - "beta", - "http://127.0.0.1:17670", - 17670, - false, - "plaintext", - ); - - let (remove_output, remove_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "remove", "beta"], - ) - .await; - assert_ne!( - remove_code, 0, - "gateway remove should reject system-only registrations:\n{remove_output}" - ); - let clean_remove = strip_ansi(&remove_output); - let normalized_remove = clean_remove - .replace(['│', '×'], " ") - .split_whitespace() - .collect::>() - .join(" "); - assert!( - normalized_remove - .contains("installed by the system and cannot be removed from user config"), - "expected system-only removal guidance:\n{clean_remove}" - ); - - let (list_output, list_code) = run_with_config( - config_dir.path(), - Some(system_dir.path()), - &["gateway", "list", "-o", "json"], - ) - .await; - assert_eq!( - list_code, 0, - "gateway list -o json should still succeed:\n{list_output}" - ); - - let items: serde_json::Value = - serde_json::from_str(&list_output).expect("parse gateway list json"); - let beta = items - .as_array() - .expect("gateway list json array") - .iter() - .find(|item| item["name"] == "beta") - .expect("find beta entry after failed remove"); - assert_eq!(beta["source"], "system"); - assert_eq!(beta["endpoint"], "http://127.0.0.1:17670"); -} From 664db2d4ca11e44575d0a2e7c8b9a45863d2e8e6 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 28 Sep 2026 10:42:34 +0200 Subject: [PATCH 2/3] fix(e2e): remove migrated tests from podman CI Signed-off-by: Evan Lezar --- e2e/rust/e2e-podman.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index d4fa2a8103..bbbcfe6fa9 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -22,8 +22,6 @@ source "${ROOT}/e2e/support/conformance.sh" # stabilized and can be added here. PODMAN_CI_TESTS=( bypass_detection - cf_auth_smoke - cli_smoke core_dump_hardening credential_gating default_image From f679ce160b3bbe2b9c47a172703e5ae512d058cf Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 23 Sep 2026 10:55:25 +0200 Subject: [PATCH 3/3] test(conformance): migrate file transfer scenarios Signed-off-by: Evan Lezar --- architecture/build.md | 4 +- crates/openshell-conformance/src/lib.rs | 7 +- .../src/scenarios/file_transfer.rs | 678 ++++++++++++++++++ .../src/scenarios/mod.rs | 5 + e2e/rust/tests/sync.rs | 642 ----------------- tests/ansible/playbooks/conformance/cli.yaml | 8 + .../conformance/cli/tests/file_transfer.rs | 40 ++ tests/suites/features/Cargo.lock | 4 +- 8 files changed, 741 insertions(+), 647 deletions(-) create mode 100644 crates/openshell-conformance/src/scenarios/file_transfer.rs delete mode 100644 e2e/rust/tests/sync.rs create mode 100644 tests/suites/conformance/cli/tests/file_transfer.rs diff --git a/architecture/build.md b/architecture/build.md index b5c05881b9..556a3a0094 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -270,7 +270,9 @@ for explicit publication. CLI conformance runs after target provisioning and operates only through the configured OpenShell CLI. The smoke scenario verifies the black-box sandbox -lifecycle by creating, inspecting, executing in, and deleting a sandbox. +lifecycle by creating, inspecting, executing in, and deleting a sandbox. The +file-transfer scenario verifies portable upload and download behavior, Git-aware +filtering, and sandbox workspace path safety. Feature suites use the same disposable guest but may provision isolated dependencies after installation. The Keycloak provider-refresh suite starts a guest-local Keycloak realm and verifies a successful OAuth refresh followed by diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index b0e4295651..7c68a48753 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -24,8 +24,10 @@ use tokio::time::sleep; use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; pub use scenarios::{ - MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, - SANDBOX_LIFECYCLE_SCENARIO, SMOKE_SCENARIO, + FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, + NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, + SMOKE_SCENARIO, }; /// An installed conformance scenario. @@ -47,6 +49,7 @@ impl Scenario { const SCENARIOS: &[Scenario] = &[ SMOKE_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, + FILE_TRANSFER_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs new file mode 100644 index 0000000000..b1cd38dc6f --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/file_transfer.rs @@ -0,0 +1,678 @@ +// 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 portable upload and download behavior through the public CLI. +pub const FILE_TRANSFER_SCENARIO: Scenario = Scenario { + name: "file-transfer", + description: "Verify sandbox uploads, downloads, Git filtering, and path safety.", + run: run_file_transfer, +}; + +/// Certify basic file and directory upload and download behavior. +pub const FILE_TRANSFER_ROUND_TRIP_SCENARIO: Scenario = Scenario { + name: "file-transfer/round-trip", + description: "Verify file and directory upload and download round trips.", + run: run_round_trip, +}; + +/// Certify Git-aware upload filtering and fallback behavior. +pub const FILE_TRANSFER_GIT_FILTERING_SCENARIO: Scenario = Scenario { + name: "file-transfer/git-filtering", + description: "Verify Git-aware upload selection and unfiltered fallback behavior.", + 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", + description: "Verify workspace boundary enforcement and safe filename handling.", + run: run_path_safety, +}; + +fn run_file_transfer(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { + Box::pin(async move { + FILE_TRANSFER_ROUND_TRIP_SCENARIO.run(runner).await?; + FILE_TRANSFER_GIT_FILTERING_SCENARIO.run(runner).await?; + FILE_TRANSFER_PATH_SAFETY_SCENARIO.run(runner).await + }) +} + +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_fallback(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"); + upload(runner, sandbox, "roundtrip/upload", &source, &remote, true).await?; + + 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, + true, + ) + .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_fallback( + 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-fallback/seed", + &format!("mkdir -p '{remote_root}/runs' && printf downloaded-payload > '{remote_seed}'"), + ) + .await?; + + let repository = local_root.join("fallback-repo"); + fs::create_dir(&repository).map_err(fs_error("create fallback repository"))?; + git_init(&repository).await?; + fs::write(repository.join(".gitignore"), "runs/\n") + .map_err(fs_error("write fallback .gitignore"))?; + let runs = repository.join("runs"); + fs::create_dir(&runs).map_err(fs_error("create ignored runs directory"))?; + download( + runner, + sandbox, + "gitignored-fallback/download-seed", + &remote_seed, + &runs, + ) + .await?; + require_exists(&runs.join("test.json"), "downloaded ignored file")?; + + let remote = format!("{remote_root}/reuploaded"); + let upload_result = upload_result( + runner, + sandbox, + "gitignored-fallback/upload", + &runs, + &remote, + false, + ) + .await?; + upload_result.require_success()?; + let output = format!("{}\n{}", upload_result.stdout(), upload_result.stderr()); + if !output.contains(".gitignore filtering excluded all files") { + return Err(upload_result.failure_diagnostic( + "upload warns that Git filtering excluded every file and falls back to an unfiltered transfer", + )); + } + + let destination = local_root.join("fallback-download"); + fs::create_dir(&destination).map_err(fs_error("create fallback destination"))?; + download( + runner, + sandbox, + "gitignored-fallback/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:?} succeeds")) + .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 index af6280fe1f..880034d4d8 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -3,10 +3,15 @@ //! Registered, portable conformance scenarios. +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, FILE_TRANSFER_SCENARIO, +}; pub use policy_behavior::{ MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, }; diff --git a/e2e/rust/tests/sync.rs b/e2e/rust/tests/sync.rs deleted file mode 100644 index a57793a74f..0000000000 --- a/e2e/rust/tests/sync.rs +++ /dev/null @@ -1,642 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e")] - -//! E2E test: bidirectional file upload/download with a sandbox. -//! -//! Prerequisites: -//! - A running openshell gateway (`mise run gateway:docker`) -//! - The `openshell` binary (built automatically from the workspace) - -use std::fs; -use std::io::Write; -use std::process::Stdio; - -use sha2::{Digest, Sha256}; - -use openshell_e2e::harness::sandbox::SandboxGuard; - -/// Create a long-running sandbox, upload and download files, and verify -/// contents. -/// -/// Covers: -/// 1. Directory round-trip (nested files) -/// 2. Large file round-trip (~512 KiB) with SHA-256 checksum verification -/// 3. Single-file round-trip -#[tokio::test] -async fn sandbox_file_upload_download_round_trip() { - // --------------------------------------------------------------- - // Step 1 — Create a sandbox with `--keep` running `sleep infinity`. - // --------------------------------------------------------------- - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - - // --------------------------------------------------------------- - // Step 2 — Upload: push a local directory into the sandbox. - // --------------------------------------------------------------- - let upload_dir = tmpdir.path().join("upload"); - fs::create_dir_all(upload_dir.join("subdir")).expect("create upload dirs"); - fs::write(upload_dir.join("greeting.txt"), "hello-from-local").expect("write greeting.txt"); - fs::write(upload_dir.join("subdir/nested.txt"), "nested-content").expect("write nested.txt"); - - let upload_str = upload_dir.to_str().expect("upload path is UTF-8"); - guard - .upload(upload_str, "/sandbox/uploaded") - .await - .expect("upload directory"); - - // --------------------------------------------------------------- - // Step 3 — Download: pull the uploaded files back and verify. - // --------------------------------------------------------------- - let download_dir = tmpdir.path().join("download"); - fs::create_dir_all(&download_dir).expect("create download dir"); - - let download_str = download_dir.to_str().expect("download path is UTF-8"); - guard - .download("/sandbox/uploaded/upload", download_str) - .await - .expect("download directory"); - - // Verify top-level file. - let greeting = fs::read_to_string(download_dir.join("greeting.txt")) - .expect("read greeting.txt after download"); - assert_eq!( - greeting, "hello-from-local", - "greeting.txt content mismatch" - ); - - // Verify nested file. - let nested = fs::read_to_string(download_dir.join("subdir/nested.txt")) - .expect("read subdir/nested.txt after download"); - assert_eq!( - nested, "nested-content", - "subdir/nested.txt content mismatch" - ); - - // --------------------------------------------------------------- - // Step 4 — Large-file round-trip (~512 KiB) to exercise multi-chunk - // SSH transport. - // --------------------------------------------------------------- - let large_dir = tmpdir.path().join("large_upload"); - fs::create_dir_all(&large_dir).expect("create large_upload dir"); - - let large_file = large_dir.join("large.bin"); - { - let mut f = fs::File::create(&large_file).expect("create large.bin"); - let mut rng_data = vec![0u8; 512 * 1024]; // 512 KiB - rand::fill(&mut rng_data[..]); - f.write_all(&rng_data).expect("write large.bin"); - } - - let expected_hash = { - let data = fs::read(&large_file).expect("read large.bin for hash"); - let mut hasher = Sha256::new(); - hasher.update(&data); - hex::encode(hasher.finalize()) - }; - - let large_dir_str = large_dir.to_str().expect("large_dir path is UTF-8"); - guard - .upload(large_dir_str, "/sandbox/large_test") - .await - .expect("upload large file"); - - let large_down = tmpdir.path().join("large_download"); - fs::create_dir_all(&large_down).expect("create large_download dir"); - - let large_down_str = large_down.to_str().expect("large_down path is UTF-8"); - guard - .download("/sandbox/large_test/large_upload", large_down_str) - .await - .expect("download large file"); - - let actual_data = - fs::read(large_down.join("large.bin")).expect("read large.bin after download"); - let actual_hash = { - let mut hasher = Sha256::new(); - hasher.update(&actual_data); - hex::encode(hasher.finalize()) - }; - - assert_eq!( - expected_hash, actual_hash, - "large.bin SHA-256 mismatch after round-trip" - ); - assert_eq!( - actual_data.len(), - 512 * 1024, - "large.bin size mismatch: expected {} bytes, got {}", - 512 * 1024, - actual_data.len() - ); - - // --------------------------------------------------------------- - // Step 5 — Single-file round-trip. - // --------------------------------------------------------------- - let single_file = tmpdir.path().join("single.txt"); - fs::write(&single_file, "single-file-payload").expect("write single.txt"); - - let single_str = single_file.to_str().expect("single path is UTF-8"); - guard - .upload(single_str, "/sandbox") - .await - .expect("upload single file"); - - let single_down = tmpdir.path().join("single_down"); - fs::create_dir_all(&single_down).expect("create single_down dir"); - - let single_down_str = single_down.to_str().expect("single_down path is UTF-8"); - guard - .download("/sandbox/single.txt", single_down_str) - .await - .expect("download single file"); - - let single_content = - fs::read_to_string(single_down.join("single.txt")).expect("read single.txt after download"); - assert_eq!( - single_content, "single-file-payload", - "single.txt content mismatch" - ); - - // --------------------------------------------------------------- - // Cleanup (guard also cleans up on drop). - // --------------------------------------------------------------- - guard.cleanup().await; -} - -/// Verify that `sandbox upload` respects `.gitignore` by default. -/// -/// Creates a temporary git repository with a `.gitignore` that excludes -/// `*.log` files, uploads the directory (without `--no-git-ignore`), and -/// confirms that tracked files arrive but ignored files do not. -#[tokio::test] -async fn upload_respects_gitignore_by_default() { - // --------------------------------------------------------------- - // Step 1 — Create a sandbox with `--keep`. - // --------------------------------------------------------------- - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - // --------------------------------------------------------------- - // Step 2 — Set up a temp git repo with tracked + ignored files. - // --------------------------------------------------------------- - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let repo = tmpdir.path().join("repo"); - fs::create_dir_all(&repo).expect("create repo dir"); - - // Initialize git repo and add files. - let git_init = tokio::process::Command::new("git") - .args(["init"]) - .current_dir(&repo) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await - .expect("git init"); - assert!(git_init.success(), "git init should succeed"); - - // Configure git user for the commit. - let _ = tokio::process::Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(&repo) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; - let _ = tokio::process::Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(&repo) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; - - // Create .gitignore, a tracked file, and an ignored file. - fs::write(repo.join(".gitignore"), "*.log\nbuild/\n").expect("write .gitignore"); - fs::write(repo.join("tracked.txt"), "i-am-tracked").expect("write tracked.txt"); - fs::write(repo.join("ignored.log"), "i-should-be-filtered").expect("write ignored.log"); - fs::create_dir_all(repo.join("build")).expect("create build dir"); - fs::write(repo.join("build/output.bin"), "build-artifact").expect("write build/output.bin"); - - // git add + commit so git ls-files works. - let _ = tokio::process::Command::new("git") - .args(["add", "."]) - .current_dir(&repo) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await - .expect("git add"); - let _ = tokio::process::Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(&repo) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await - .expect("git commit"); - - // --------------------------------------------------------------- - // Step 3 — Upload WITH gitignore filtering (default). - // --------------------------------------------------------------- - let repo_str = repo.to_str().expect("repo path is UTF-8"); - guard - .upload_with_gitignore(repo_str, "/sandbox/filtered", &repo) - .await - .expect("upload with gitignore filtering"); - - // --------------------------------------------------------------- - // Step 4 — Verify: tracked file exists, ignored files do not. - // --------------------------------------------------------------- - // Download the uploaded directory and verify contents. - let download_dir = tmpdir.path().join("verify"); - fs::create_dir_all(&download_dir).expect("create verify dir"); - let download_str = download_dir.to_str().expect("verify path is UTF-8"); - - guard - .download("/sandbox/filtered", download_str) - .await - .expect("download filtered upload"); - - // Filtered uploads of a directory preserve the source basename, so - // contents land under `/repo/...` (matches `openshell sandbox - // upload ` semantics from the unfiltered path). - let uploaded_root = download_dir.join("repo"); - - // tracked.txt should be present. - let tracked = fs::read_to_string(uploaded_root.join("tracked.txt")) - .expect("tracked.txt should exist after filtered upload"); - assert_eq!(tracked, "i-am-tracked", "tracked.txt content mismatch"); - - // .gitignore itself should be present (it's tracked). - assert!( - uploaded_root.join(".gitignore").exists(), - ".gitignore should be uploaded (it's a tracked file)" - ); - - // ignored.log should NOT be present. - assert!( - !uploaded_root.join("ignored.log").exists(), - "ignored.log should be filtered out by .gitignore" - ); - - // build/ directory should NOT be present. - assert!( - !uploaded_root.join("build").exists(), - "build/ directory should be filtered out by .gitignore" - ); - - // --------------------------------------------------------------- - // Cleanup. - // --------------------------------------------------------------- - guard.cleanup().await; -} - -/// Verify that uploading a single tracked file from inside a git repo does not -/// expand to the entire repository. -#[tokio::test] -async fn upload_single_file_from_git_repo_only_uploads_that_file() { - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let repo = tmpdir.path().join("repo"); - fs::create_dir_all(repo.join("nested")).expect("create repo dir"); - - let git_init = tokio::process::Command::new("git") - .args(["init"]) - .current_dir(&repo) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await - .expect("git init"); - assert!(git_init.success(), "git init should succeed"); - - fs::write(repo.join(".gitignore"), "*.log\n").expect("write .gitignore"); - fs::write(repo.join("nested/config.txt"), "single-file-from-repo").expect("write config.txt"); - fs::write(repo.join("tracked.txt"), "should-not-upload").expect("write tracked.txt"); - fs::write(repo.join("ignored.log"), "ignored").expect("write ignored.log"); - - let local_path = repo.join("nested/config.txt"); - let local_str = local_path.to_str().expect("local path is UTF-8"); - guard - .upload_with_gitignore(local_str, "/sandbox/single-file", &repo) - .await - .expect("upload single tracked file with gitignore"); - - let download_dir = tmpdir.path().join("single-file-download"); - fs::create_dir_all(&download_dir).expect("create download dir"); - let download_str = download_dir.to_str().expect("download path is UTF-8"); - - guard - .download("/sandbox/single-file", download_str) - .await - .expect("download uploaded single file"); - - let uploaded = fs::read_to_string(download_dir.join("config.txt")).expect("read config.txt"); - assert_eq!(uploaded, "single-file-from-repo"); - assert!( - !download_dir.join("tracked.txt").exists(), - "tracked.txt should not have been uploaded" - ); - assert!( - !download_dir.join("ignored.log").exists(), - "ignored.log should not have been uploaded" - ); - - guard.cleanup().await; -} - -/// Pre-populate a single file on the sandbox side via `sandbox exec` and pull -/// it back with `sandbox download`. Exercises `sandbox_sync_down_file` in -/// isolation (no upload phase). -#[tokio::test] -async fn sandbox_download_file_only() { - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - guard - .exec(&[ - "sh", - "-c", - "printf greeting-payload > /sandbox/greeting.txt", - ]) - .await - .expect("seed greeting.txt inside the sandbox"); - - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let dest = tmpdir.path().join("out"); - fs::create_dir_all(&dest).expect("create download dir"); - let dest_str = dest.to_str().expect("dest path is UTF-8"); - - guard - .download("/sandbox/greeting.txt", dest_str) - .await - .expect("download greeting.txt"); - - let actual = fs::read_to_string(dest.join("greeting.txt")).expect("read greeting.txt"); - assert_eq!( - actual, "greeting-payload", - "downloaded file content mismatch" - ); - - guard.cleanup().await; -} - -/// Pre-populate a small directory tree on the sandbox side via `sandbox exec` -/// and pull it recursively with `sandbox download`. Exercises -/// `sandbox_sync_down_directory` in isolation (no upload phase). -#[tokio::test] -async fn sandbox_download_directory_only() { - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - guard - .exec(&[ - "sh", - "-c", - "mkdir -p /sandbox/tree/sub \ - && printf top-level > /sandbox/tree/root.txt \ - && printf nested > /sandbox/tree/sub/child.txt", - ]) - .await - .expect("seed directory tree inside the sandbox"); - - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let dest = tmpdir.path().join("out"); - fs::create_dir_all(&dest).expect("create download dir"); - let dest_str = dest.to_str().expect("dest path is UTF-8"); - - guard - .download("/sandbox/tree", dest_str) - .await - .expect("download directory tree"); - - let root = fs::read_to_string(dest.join("root.txt")).expect("read root.txt"); - assert_eq!(root, "top-level", "root.txt content mismatch"); - let child = fs::read_to_string(dest.join("sub/child.txt")).expect("read sub/child.txt"); - assert_eq!(child, "nested", "sub/child.txt content mismatch"); - - guard.cleanup().await; -} - -/// Assert that an error string carries the two markers of a remote-side -/// canonicalisation refusal: `resolves to` (proving `realpath -e` ran) and -/// `outside the` + `sandbox workspace` (proving the boundary check fired). -/// -/// miette renders the long single-line error wrapped across multiple lines -/// at terminal width and inserts a `│ ` continuation marker, so a single -/// long substring like `"outside the sandbox workspace"` is fragile — -/// match the short markers individually instead. -fn assert_resolves_outside_workspace(err: &str, label: &str) { - assert!( - err.contains("resolves to") - && err.contains("outside the") - && err.contains("sandbox workspace"), - "expected resolves-outside-workspace error for {label}, got: {err}" - ); -} - -/// Regression for the Codex high-severity finding: `validate_sandbox_source_path` -/// is purely lexical, so a sandbox-side symlink that escapes `/sandbox` could -/// slip through and let `tar -C` follow the link into the host filesystem. -/// `resolve_sandbox_source_path` re-validates after `realpath -e` resolves -/// every component. Cover both shapes from the Codex example: the symlink as -/// the full source (directory case) and the symlink as a component of the -/// source path (file case). -#[tokio::test] -async fn sandbox_download_rejects_symlinks_pointing_outside_workspace() { - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - guard - .exec(&[ - "sh", - "-c", - "ln -s /etc /sandbox/etc-link && ln -s /etc/passwd /sandbox/passwd-link", - ]) - .await - .expect("plant escape symlinks inside the sandbox"); - - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let dest = tmpdir.path().join("out"); - fs::create_dir_all(&dest).expect("create download dir"); - let dest_str = dest.to_str().expect("dest path is UTF-8"); - - // Directory-source symlink: /sandbox/etc-link -> /etc - let err = guard - .download("/sandbox/etc-link", dest_str) - .await - .expect_err("download of a directory symlink to /etc must be refused"); - assert_resolves_outside_workspace(&err, "directory symlink"); - - // File-source symlink: /sandbox/passwd-link -> /etc/passwd - let err = guard - .download("/sandbox/passwd-link", dest_str) - .await - .expect_err("download of a file symlink to /etc/passwd must be refused"); - assert_resolves_outside_workspace(&err, "file symlink"); - - // Path with a symlinked component: /sandbox/etc-link/passwd - let err = guard - .download("/sandbox/etc-link/passwd", dest_str) - .await - .expect_err("download via a symlinked path component must be refused"); - assert_resolves_outside_workspace(&err, "symlinked component"); - - // Sanity check: the host destination should still be empty — no file from - // /etc should have leaked through on any of the three attempts. - assert!( - !dest.join("passwd").exists() && !dest.join("etc-link").exists(), - "no file should have been written when the source resolves outside the workspace" - ); - - guard.cleanup().await; -} - -/// Regression for the Codex medium-severity finding: the single-file tar -/// invocation must wrap the basename behind a `--` separator, otherwise a -/// sandbox-side filename whose basename starts with `--` (e.g. created by a -/// malicious agent inside the sandbox) is parsed by GNU tar as an option -/// rather than a member to archive. -#[tokio::test] -async fn sandbox_download_handles_dash_leading_basename() { - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - // Quoting the redirect target keeps the leading dashes intact across the - // shell parse; the sandbox-side filesystem ends up with a basename that - // would be parsed as an option by an unprotected tar invocation. - guard - .exec(&[ - "sh", - "-c", - "printf dash-payload > '/sandbox/--checkpoint-action=evil'", - ]) - .await - .expect("seed dash-leading file inside the sandbox"); - - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let dest = tmpdir.path().join("out"); - fs::create_dir_all(&dest).expect("create download dir"); - let dest_str = dest.to_str().expect("dest path is UTF-8"); - - guard - .download("/sandbox/--checkpoint-action=evil", dest_str) - .await - .expect("download dash-leading basename"); - - let actual = - fs::read_to_string(dest.join("--checkpoint-action=evil")).expect("read dash-leading file"); - assert_eq!( - actual, "dash-payload", - "dash-leading file content mismatch — tar may have parsed the basename as an option" - ); - - guard.cleanup().await; -} - -/// Regression for #1778: uploading a directory whose contents are entirely -/// excluded by `.gitignore` must still transfer the files instead of -/// silently reporting success with zero bytes sent. -/// -/// Reproduces the reported workflow: download files from a sandbox into a -/// local git repo where the target directory is gitignored, then re-upload -/// to the same sandbox at a different path. -#[tokio::test] -async fn upload_gitignored_directory_falls_back_to_unfiltered() { - let mut guard = - SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") - .await - .expect("sandbox create --keep"); - - // Seed a file inside the sandbox so we have something to download. - guard - .exec(&[ - "sh", - "-c", - "mkdir -p /sandbox/runs && printf downloaded-payload > /sandbox/runs/test.json", - ]) - .await - .expect("seed runs/test.json inside the sandbox"); - - // Download the file into a local git repo where `runs/` is gitignored. - let tmpdir = tempfile::tempdir().expect("create tmpdir"); - let repo = tmpdir.path().join("repo"); - fs::create_dir_all(&repo).expect("create repo dir"); - - let git_init = tokio::process::Command::new("git") - .args(["init"]) - .current_dir(&repo) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await - .expect("git init"); - assert!(git_init.success(), "git init should succeed"); - - fs::write(repo.join(".gitignore"), "runs/\n").expect("write .gitignore"); - - let download_dest = repo.join("runs"); - fs::create_dir_all(&download_dest).expect("create runs dir"); - let download_str = download_dest.to_str().expect("download path is UTF-8"); - - guard - .download("/sandbox/runs/test.json", download_str) - .await - .expect("download runs/test.json"); - - let local_file = download_dest.join("test.json"); - assert!(local_file.exists(), "downloaded file should exist locally"); - - // Re-upload the gitignored directory (without --no-git-ignore). - let upload_str = download_dest.to_str().expect("upload path is UTF-8"); - let upload_output = guard - .upload_with_gitignore(upload_str, "/sandbox/reuploaded", &repo) - .await - .expect("upload gitignored directory"); - - assert!( - upload_output.contains(".gitignore filtering excluded all files"), - "expected fallback warning in upload output, got: {upload_output}" - ); - - // Download the re-uploaded file and verify it arrived. - let verify_dir = tmpdir.path().join("verify"); - fs::create_dir_all(&verify_dir).expect("create verify dir"); - let verify_str = verify_dir.to_str().expect("verify path is UTF-8"); - - guard - .download("/sandbox/reuploaded", verify_str) - .await - .expect("download reuploaded directory"); - - // The upload wraps contents under the source basename, so the file - // lands at verify/runs/test.json. - let reuploaded = fs::read_to_string(verify_dir.join("runs/test.json")) - .expect("reuploaded test.json should exist"); - assert_eq!( - reuploaded, "downloaded-payload", - "reuploaded file content mismatch — gitignored directory was not uploaded" - ); - - guard.cleanup().await; -} diff --git a/tests/ansible/playbooks/conformance/cli.yaml b/tests/ansible/playbooks/conformance/cli.yaml index d21a60a35b..fb3220b07e 100644 --- a/tests/ansible/playbooks/conformance/cli.yaml +++ b/tests/ansible/playbooks/conformance/cli.yaml @@ -9,6 +9,13 @@ - name: Wait for SSH ansible.builtin.wait_for_connection: + - name: Install conformance test dependencies + become: true + ansible.builtin.package: + name: + - git + state: present + - name: Create OpenShell conformance test directory become: true ansible.builtin.file: @@ -64,6 +71,7 @@ - "1" - --filterset - "{{ conformance_filter | default('all()') }}" + - --no-fail-fast environment: OPENSHELL_BIN: "{{ openshell_cli.stdout }}" register: conformance_result diff --git a/tests/suites/conformance/cli/tests/file_transfer.rs b/tests/suites/conformance/cli/tests/file_transfer.rs new file mode 100644 index 0000000000..167506b87b --- /dev/null +++ b/tests/suites/conformance/cli/tests/file_transfer.rs @@ -0,0 +1,40 @@ +// 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/features/Cargo.lock b/tests/suites/features/Cargo.lock index b952a45ab1..268f8bac92 100644 --- a/tests/suites/features/Cargo.lock +++ b/tests/suites/features/Cargo.lock @@ -1130,9 +1130,9 @@ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno",