diff --git a/TESTING.md b/TESTING.md index a3936ad649..902d8c19ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -424,11 +424,12 @@ Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. - Conformance (`tests/suites/conformance`) - the testkit `conformance` crate: - lifecycle, the CLI, persistent workspaces, file transfer, and the policy - advisor. Driver E2E runs it with `openshell-conformance` against the lane's - gateway and writes a JUnit report under `results/`; set - `OPENSHELL_CONFORMANCE_BIN` to use a prebuilt program, as Branch E2E does - with the static build it makes once per architecture. The + lifecycle, the CLI, persistent workspaces, file transfer, the policy + advisor, and credential gating. Driver E2E runs it with + `openshell-conformance` against the lane's gateway and writes a JUnit report + under `results/`; set `OPENSHELL_CONFORMANCE_BIN` to use a prebuilt program, + as Branch E2E does with the static build it makes once per architecture. + The installed-artifact conformance lanes run the same crate through `openshell-test-runner` (see above). - Driver suites (`--features e2e-docker`, `e2e-podman`, `e2e-kubernetes`, or @@ -656,6 +657,8 @@ explains the isolation backend separation behind the capability-free runtime. On a **remote** cluster, drop the `e2e-host-gateway` feature. Those tests rely on the sandbox-side `host.openshell.internal` alias reaching the machine running the tests, which is unreachable from pods on a remote cluster, so they fail. +The conformance suite's `credential_gating` tests rely on the alias too and +fail on a remote cluster; the feature selection doesn't skip them. Left enabled, the `host_gateway_alias` suite fails because `host.openshell.internal` does not resolve inside the pod, so the gateway SSRF-denies the request (`DNS resolution failed` / `ssrf_denied`) — a networking diff --git a/e2e/helm-plugins/openshell-external-compute-driver/host-gateway/driver-env-patch.yaml b/e2e/helm-plugins/openshell-external-compute-driver/host-gateway/driver-env-patch.yaml new file mode 100644 index 0000000000..3c829a6cb8 --- /dev/null +++ b/e2e/helm-plugins/openshell-external-compute-driver/host-gateway/driver-env-patch.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: openshell +spec: + template: + spec: + containers: + - name: kubernetes-compute-driver + env: + - name: OPENSHELL_HOST_GATEWAY_IP + value: replaced-by-kustomize diff --git a/e2e/helm-plugins/openshell-external-compute-driver/host-gateway/kustomization.yaml b/e2e/helm-plugins/openshell-external-compute-driver/host-gateway/kustomization.yaml new file mode 100644 index 0000000000..2afb0e5262 --- /dev/null +++ b/e2e/helm-plugins/openshell-external-compute-driver/host-gateway/kustomization.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# The in-process driver reads server.hostGatewayIP from the rendered gateway +# TOML. The external driver reads it from its own environment, so copy the +# chart's rendered value into that container. +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component +patches: + - path: driver-env-patch.yaml + target: + group: apps + version: v1 + kind: StatefulSet + name: openshell +replacements: + - source: + group: apps + version: v1 + kind: StatefulSet + name: openshell + fieldPath: spec.template.spec.hostAliases.0.ip + targets: + - select: + group: apps + version: v1 + kind: StatefulSet + name: openshell + fieldPaths: + - spec.template.spec.containers.[name=kubernetes-compute-driver].env.[name=OPENSHELL_HOST_GATEWAY_IP].value diff --git a/e2e/helm-plugins/openshell-external-compute-driver/post-renderer.sh b/e2e/helm-plugins/openshell-external-compute-driver/post-renderer.sh index 43ee52140a..c05b6b553f 100755 --- a/e2e/helm-plugins/openshell-external-compute-driver/post-renderer.sh +++ b/e2e/helm-plugins/openshell-external-compute-driver/post-renderer.sh @@ -16,4 +16,11 @@ cp "${plugin_dir}/kustomization.yaml" "${work_dir}/kustomization.yaml" cp "${plugin_dir}/workload-patch.yaml" "${work_dir}/workload-patch.yaml" tee "${work_dir}/rendered.yaml" >/dev/null +# server.hostGatewayIP renders the gateway pod's hostAliases entry. +if grep -q 'host\.openshell\.internal' "${work_dir}/rendered.yaml" \ + && grep -q '^ *hostAliases:' "${work_dir}/rendered.yaml"; then + cp -R "${plugin_dir}/host-gateway" "${work_dir}/host-gateway" + printf 'components:\n - host-gateway\n' >>"${work_dir}/kustomization.yaml" +fi + kubectl kustomize "${work_dir}" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index b0938d9625..2af824018c 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -204,11 +204,6 @@ name = "websocket_conformance" path = "tests/websocket_conformance.rs" required-features = ["e2e-host-gateway"] -[[test]] -name = "credential_gating" -path = "tests/credential_gating.rs" -required-features = ["e2e-host-gateway"] - [[test]] name = "user_namespaces" path = "tests/user_namespaces.rs" diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index 0d6cd5008c..495863fede 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -25,7 +25,6 @@ PODMAN_CI_TESTS=( bypass_detection ephemeral_cleanup core_dump_hardening - credential_gating default_image driver_config_volume edge_tunnel_e2e diff --git a/e2e/rust/tests/credential_gating.rs b/e2e/rust/tests/credential_gating.rs deleted file mode 100644 index 110e02cb1e..0000000000 --- a/e2e/rust/tests/credential_gating.rs +++ /dev/null @@ -1,1057 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e")] - -//! E2E coverage for credentialed endpoint admission and REST body backstops. -//! -//! Each test installs its own provider and profile under a unique name, so the -//! tests can run in parallel. Each test prints its steps to stderr with the -//! elapsed time, so a timeout shows which step was slow. - -use std::fmt::Display; -use std::io::Write; -use std::process::Stdio; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use base64::Engine as _; -use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd}; -use openshell_e2e::harness::sandbox::SandboxGuard; -use sha1::{Digest, Sha1}; -use tempfile::NamedTempFile; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::task::JoinHandle; - -const TEST_HOST: &str = "host.openshell.internal"; -const TOKEN_ENV: &str = "E2E_GATING_TOKEN"; -const TEST_SECRET: &str = "e2e-gating-secret-value"; -const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:"; -const KEY_PRESENCE_SCRIPT: &str = r#"if [ "${E2E_GATING_TOKEN+x}" != x ]; then - echo TOKEN_ABSENT -else - case "$E2E_GATING_TOKEN" in - openshell:resolve:env:*) echo TOKEN_PLACEHOLDER ;; - *) echo TOKEN_UNSAFE ;; - esac -fi"#; -const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - -async fn run_cli(args: &[&str]) -> (bool, String) { - let mut command = openshell_cmd(); - command - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let output = command.output().await.expect("spawn openshell CLI"); - ( - output.status.success(), - format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ), - ) -} - -/// Prints a test's steps to stderr with the time since the test started. -#[derive(Clone, Copy)] -struct Progress { - scenario: &'static str, - started: Instant, -} - -impl Progress { - fn start(scenario: &'static str) -> Self { - let progress = Self { - scenario, - started: Instant::now(), - }; - progress.step("start"); - progress - } - - fn step(self, message: impl Display) { - eprintln!( - "credential_gating[{}] +{:.1}s {message}", - self.scenario, - self.started.elapsed().as_secs_f64() - ); - } -} - -fn utf8_path(file: &NamedTempFile) -> Result<&str, String> { - file.path() - .to_str() - .ok_or_else(|| format!("{} is not UTF-8", file.path().display())) -} - -/// Retry a delete until it takes effect. -/// -/// The gateway refuses to delete a provider or a profile that a sandbox still -/// references, and sandbox teardown drains asynchronously, so a single attempt -/// can be rejected right after a sandbox was removed. This runs from `Drop`, -/// so it blocks instead of awaiting. -fn delete_until_gone(args: &[&str]) -> Result<(), String> { - const ATTEMPTS: u32 = 40; - let binary = openshell_bin(); - let mut last_output = String::new(); - for _ in 0..ATTEMPTS { - match std::process::Command::new(&binary) - .args(args) - .stdin(Stdio::null()) - .output() - { - Ok(output) => { - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - if output.status.success() || combined.to_lowercase().contains("not found") { - return Ok(()); - } - last_output = combined; - } - Err(error) => last_output = format!("spawn openshell CLI: {error}"), - } - std::thread::sleep(Duration::from_millis(500)); - } - Err(format!( - "'{}' still failing after {ATTEMPTS} attempts:\n{last_output}", - args.join(" ") - )) -} - -/// A provider and the profile it uses, both under one name unique to the -/// test. Dropping it deletes both, also when the test fails. -struct GatingProvider { - name: String, - progress: Progress, -} - -impl GatingProvider { - async fn install_with_endpoints( - progress: Progress, - rest_port: u16, - websocket_port: u16, - ) -> Result { - let name = unique_name(progress.scenario); - let profile = write_provider_profile(&name, rest_port, websocket_port)?; - Self::install(progress, name, &profile).await - } - - async fn install_endpointless(progress: Progress) -> Result { - let name = unique_name(progress.scenario); - let profile = write_endpointless_provider_profile(&name)?; - Self::install(progress, name, &profile).await - } - - async fn install( - progress: Progress, - name: String, - profile: &NamedTempFile, - ) -> Result { - progress.step(format_args!("importing profile {name}")); - let (imported, output) = - run_cli(&["profile", "import", "--file", utf8_path(profile)?]).await; - if !imported { - return Err(format!("profile import failed:\n{output}")); - } - let provider = Self { name, progress }; - progress.step(format_args!("creating provider {}", provider.name)); - let credential = format!("{TOKEN_ENV}={TEST_SECRET}"); - let (created, output) = run_cli(&[ - "provider", - "create", - "--name", - &provider.name, - "--type", - &provider.name, - "--credential", - &credential, - ]) - .await; - if !created { - return Err(format!("provider create failed:\n{output}")); - } - Ok(provider) - } -} - -impl Drop for GatingProvider { - fn drop(&mut self) { - self.progress - .step(format_args!("deleting provider and profile {}", self.name)); - for kind in ["provider", "profile"] { - if let Err(error) = delete_until_gone(&[kind, "delete", &self.name]) { - self.progress - .step(format_args!("{kind} cleanup did not settle: {error}")); - } - } - self.progress.step("cleanup finished"); - } -} - -/// A provider and profile name that does not collide with parallel tests or -/// earlier runs against the same gateway. -fn unique_name(scenario: &str) -> String { - format!( - "e2e-cg-{scenario}-{}-{:08x}", - std::process::id(), - rand::random::() - ) -} - -fn write_provider_profile( - profile_id: &str, - rest_port: u16, - websocket_port: u16, -) -> Result { - let mut file = tempfile::Builder::new() - .suffix(".yaml") - .tempfile() - .map_err(|error| format!("create profile: {error}"))?; - let profile = format!( - r"id: {profile_id} -display_name: E2E Credential Gating -category: other -credentials: - - name: token - env_vars: [{TOKEN_ENV}] - required: true - auth_style: bearer - header_name: authorization -endpoints: - - host: {TEST_HOST} - port: {rest_port} - protocol: rest - access: full - - host: {TEST_HOST} - port: {websocket_port} - protocol: websocket - access: read-write -binaries: - - path: /usr/bin/python* - - path: /usr/local/bin/python* - - path: /sandbox/.uv/python/*/bin/python* -", - ); - file.write_all(profile.as_bytes()) - .map_err(|error| format!("write profile: {error}"))?; - file.flush() - .map_err(|error| format!("flush profile: {error}"))?; - Ok(file) -} - -fn write_endpointless_provider_profile(profile_id: &str) -> Result { - let mut file = tempfile::Builder::new() - .suffix(".yaml") - .tempfile() - .map_err(|error| format!("create endpointless profile: {error}"))?; - let profile = format!( - r"id: {profile_id} -display_name: E2E Endpointless Credential Gating -category: other -credentials: - - name: token - env_vars: [{TOKEN_ENV}] - required: true - auth_style: bearer - header_name: authorization -binaries: - - path: /usr/bin/python* - - path: /usr/local/bin/python* - - path: /sandbox/.uv/python/*/bin/python* -", - ); - file.write_all(profile.as_bytes()) - .map_err(|error| format!("write endpointless profile: {error}"))?; - file.flush() - .map_err(|error| format!("flush endpointless profile: {error}"))?; - Ok(file) -} - -#[derive(Clone, Copy)] -enum EndpointMode { - L4, - TlsSkip, - L4OptIn, - RestBody { rewrite: bool }, - WebSocket, -} - -#[derive(Clone, Copy)] -enum CredentialSource { - ProviderProfile, - PolicyBinding, -} - -fn write_policy( - port: u16, - mode: EndpointMode, - credential_source: CredentialSource, - provider: &str, -) -> Result { - let mut file = NamedTempFile::new().map_err(|error| format!("create policy: {error}"))?; - let endpoint_options = match mode { - EndpointMode::L4 => String::new(), - EndpointMode::TlsSkip => { - " protocol: rest\n access: full\n tls: skip\n".to_string() - } - EndpointMode::L4OptIn => " allow_uninspected_credentials: true\n".to_string(), - EndpointMode::RestBody { rewrite } => format!( - " protocol: rest\n access: full\n request_body_credential_rewrite: {rewrite}\n" - ), - EndpointMode::WebSocket => { - " protocol: websocket\n access: read-write\n".to_string() - } - }; - let credential_binding = match credential_source { - CredentialSource::ProviderProfile => String::new(), - CredentialSource::PolicyBinding => { - format!(" credential_binding:\n provider: {provider}\n") - } - }; - let policy = format!( - r"version: 1 -filesystem_policy: - include_workdir: true - read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] -landlock: - compatibility: best_effort -process: - run_as_user: sandbox - run_as_group: sandbox -network_policies: - credential_gating: - name: credential_gating - endpoints: - - host: {TEST_HOST} - port: {port} -{endpoint_options}{credential_binding} binaries: - - path: /usr/bin/python* - - path: /usr/local/bin/python* - - path: /sandbox/.uv/python/*/bin/python* -", - ); - file.write_all(policy.as_bytes()) - .map_err(|error| format!("write policy: {error}"))?; - file.flush() - .map_err(|error| format!("flush policy: {error}"))?; - Ok(file) -} - -#[derive(Debug, Default, Clone, Copy)] -struct BodyObservation { - saw_placeholder: bool, - saw_secret: bool, -} - -struct HttpProbeServer { - port: u16, - observations: Arc>>, - task: JoinHandle<()>, -} - -impl HttpProbeServer { - async fn start() -> Result { - let listener = TcpListener::bind(("0.0.0.0", 0)) - .await - .map_err(|error| format!("bind HTTP probe: {error}"))?; - let port = listener - .local_addr() - .map_err(|error| format!("read HTTP probe address: {error}"))? - .port(); - let observations = Arc::new(Mutex::new(Vec::new())); - let task_observations = Arc::clone(&observations); - let task = tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let observations = Arc::clone(&task_observations); - tokio::spawn(async move { - let _ = handle_http_probe(stream, observations).await; - }); - } - }); - Ok(Self { - port, - observations, - task, - }) - } - - async fn wait_for_observations(&self, count: usize) -> Vec { - for _ in 0..100 { - let observations = self.observations.lock().unwrap().clone(); - if observations.len() >= count { - return observations; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - self.observations.lock().unwrap().clone() - } -} - -impl Drop for HttpProbeServer { - fn drop(&mut self) { - self.task.abort(); - } -} - -struct BinaryWebSocketProbeServer { - port: u16, - handshake_seen: Arc, - binary_seen: Arc, - task: JoinHandle<()>, -} - -impl BinaryWebSocketProbeServer { - async fn start() -> Result { - let listener = TcpListener::bind(("0.0.0.0", 0)) - .await - .map_err(|error| format!("bind WebSocket probe: {error}"))?; - let port = listener - .local_addr() - .map_err(|error| format!("read WebSocket probe address: {error}"))? - .port(); - let handshake_seen = Arc::new(AtomicBool::new(false)); - let binary_seen = Arc::new(AtomicBool::new(false)); - let task_handshake_seen = Arc::clone(&handshake_seen); - let task_binary_seen = Arc::clone(&binary_seen); - let task = tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let handshake_seen = Arc::clone(&task_handshake_seen); - let binary_seen = Arc::clone(&task_binary_seen); - tokio::spawn(async move { - let _ = - handle_binary_websocket_probe(stream, handshake_seen, binary_seen).await; - }); - } - }); - Ok(Self { - port, - handshake_seen, - binary_seen, - task, - }) - } - - async fn wait_for_handshake(&self) -> bool { - for _ in 0..100 { - if self.handshake_seen.load(Ordering::Acquire) { - return true; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - false - } -} - -impl Drop for BinaryWebSocketProbeServer { - fn drop(&mut self) { - self.task.abort(); - } -} - -async fn recv_until(stream: &mut TcpStream, marker: &[u8]) -> std::io::Result> { - let mut received = Vec::new(); - let mut buffer = [0_u8; 1024]; - loop { - let read = stream.read(&mut buffer).await?; - if read == 0 { - return Ok(received); - } - received.extend_from_slice(&buffer[..read]); - if received - .windows(marker.len()) - .any(|window| window == marker) - { - return Ok(received); - } - } -} - -fn websocket_header_value(request: &str, name: &str) -> Option { - request.lines().find_map(|line| { - let (header, value) = line.split_once(':')?; - header - .trim() - .eq_ignore_ascii_case(name) - .then(|| value.trim().to_string()) - }) -} - -fn websocket_accept(key: &str) -> String { - let mut hasher = Sha1::new(); - hasher.update(key.as_bytes()); - hasher.update(WEBSOCKET_GUID.as_bytes()); - base64::engine::general_purpose::STANDARD.encode(hasher.finalize()) -} - -async fn handle_binary_websocket_probe( - mut stream: TcpStream, - handshake_seen: Arc, - binary_seen: Arc, -) -> std::io::Result<()> { - let request_bytes = recv_until(&mut stream, b"\r\n\r\n").await?; - let request = String::from_utf8_lossy(&request_bytes); - let key = websocket_header_value(&request, "Sec-WebSocket-Key").ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidData, "missing WebSocket key") - })?; - let accept = websocket_accept(&key); - let response = format!( - "HTTP/1.1 101 Switching Protocols\r\n\ - Upgrade: websocket\r\n\ - Connection: Upgrade\r\n\ - Sec-WebSocket-Accept: {accept}\r\n\ - \r\n" - ); - stream.write_all(response.as_bytes()).await?; - handshake_seen.store(true, Ordering::Release); - - let mut header = [0_u8; 2]; - if tokio::time::timeout(Duration::from_secs(5), stream.read_exact(&mut header)) - .await - .is_ok_and(|result| result.is_ok()) - && header[0] & 0x0f == 0x02 - { - binary_seen.store(true, Ordering::Release); - } - Ok(()) -} - -async fn handle_http_probe( - mut stream: TcpStream, - observations: Arc>>, -) -> std::io::Result<()> { - let mut received = Vec::new(); - let mut buffer = [0_u8; 4096]; - let mut expected_total = None; - loop { - let read = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buffer)).await; - let Ok(Ok(read)) = read else { - break; - }; - if read == 0 { - break; - } - received.extend_from_slice(&buffer[..read]); - if expected_total.is_none() - && let Some(header_end) = received.windows(4).position(|window| window == b"\r\n\r\n") - { - let header_end = header_end + 4; - let headers = String::from_utf8_lossy(&received[..header_end]); - let content_length = headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - expected_total = Some(header_end + content_length); - } - if expected_total.is_some_and(|expected| received.len() >= expected) { - break; - } - } - - let observation = BodyObservation { - saw_placeholder: received - .windows(PLACEHOLDER_PREFIX.len()) - .any(|window| window == PLACEHOLDER_PREFIX.as_bytes()), - saw_secret: received - .windows(TEST_SECRET.len()) - .any(|window| window == TEST_SECRET.as_bytes()), - }; - observations.lock().unwrap().push(observation); - if expected_total.is_some_and(|expected| received.len() >= expected) { - let result = if observation.saw_secret && !observation.saw_placeholder { - "BODY_REWRITTEN" - } else if observation.saw_placeholder && !observation.saw_secret { - "BODY_TEXT" - } else { - "BODY_BAD" - }; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{result}", - result.len() - ); - stream.write_all(response.as_bytes()).await?; - } - Ok(()) -} - -fn body_client_script(port: u16) -> String { - format!( - r#" -import os -import socket - -host = {TEST_HOST:?} -port = {port} -token = os.environ[{TOKEN_ENV:?}] - -with socket.create_connection((host, port), timeout=10) as sock: - target = f"{{host}}:{{port}}" - body = ("prefix-" + token + "-suffix").encode("utf-8") - request = ( - f"POST /token HTTP/1.1\r\nHost: {{target}}\r\n" - f"Content-Type: text/plain\r\nContent-Length: {{len(body)}}\r\nConnection: close\r\n\r\n" - ).encode("ascii") + body - sock.sendall(request) - sock.settimeout(3) - response = b"" - while True: - try: - chunk = sock.recv(4096) - except socket.timeout: - break - if not chunk: - break - response += chunk - print("BODY_REWRITTEN" if b"BODY_REWRITTEN" in response else "BODY_TEXT" if b"BODY_TEXT" in response else "BODY_DENIED") -"# - ) -} - -fn binary_websocket_client_script(port: u16) -> String { - format!( - r#" -import base64 -import os -import socket -import struct - -host = {TEST_HOST:?} -port = {port} - -def recv_until(sock, marker): - data = b"" - while marker not in data: - chunk = sock.recv(4096) - if not chunk: - break - data += chunk - return data - -def recv_exact(sock, size): - data = b"" - while len(data) < size: - chunk = sock.recv(size - len(data)) - if not chunk: - break - data += chunk - return data - -with socket.create_connection((host, port), timeout=10) as sock: - target = f"{{host}}:{{port}}" - key = base64.b64encode(os.urandom(16)).decode("ascii") - request = ( - f"GET /ws HTTP/1.1\r\nHost: {{target}}\r\n" - "Upgrade: websocket\r\nConnection: Upgrade\r\n" - f"Sec-WebSocket-Key: {{key}}\r\nSec-WebSocket-Version: 13\r\n\r\n" - ) - sock.sendall(request.encode("ascii")) - if not recv_until(sock, b"\r\n\r\n").startswith(b"HTTP/1.1 101"): - raise RuntimeError("upgrade failed") - payload = b"binary-credential-channel" - mask = os.urandom(4) - masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) - frame = bytes([0x82, 0x80 | len(payload)]) + mask + masked - sock.sendall(frame) - sock.settimeout(3) - denied = False - try: - header = recv_exact(sock, 2) - if not header: - denied = True - elif len(header) == 2: - opcode = header[0] & 0x0f - masked = bool(header[1] & 0x80) - payload_length = header[1] & 0x7f - if opcode == 0x08 and not masked and payload_length < 126: - close_payload = recv_exact(sock, payload_length) - denied = ( - len(close_payload) >= 2 - and struct.unpack("!H", close_payload[:2])[0] == 1008 - ) - except socket.timeout: - denied = True - print("BINARY_DENIED" if denied else "BINARY_FORWARDED") -"# - ) -} - -async fn sandbox_create_failure( - progress: Progress, - label: &str, - provider: &str, - policy: &NamedTempFile, -) -> String { - progress.step(format_args!( - "{label}: creating sandbox, expecting rejection" - )); - let policy_path = policy.path().to_str().expect("policy path is UTF-8"); - let (success, output) = run_cli(&[ - "sandbox", - "create", - "--policy", - policy_path, - "--provider", - provider, - "--", - "echo", - "must-not-run", - ]) - .await; - assert!(!success, "sandbox create unexpectedly succeeded:\n{output}"); - progress.step(format_args!("{label}: rejected")); - output -} - -/// Create a sandbox, run one command in it, and delete it. -/// -/// Returns the create output followed by the command output. -async fn run_in_sandbox( - progress: Progress, - label: &str, - create_args: &[&str], - command: &[&str], -) -> Result { - progress.step(format_args!("{label}: creating sandbox")); - let mut sandbox = SandboxGuard::create(create_args).await?; - progress.step(format_args!( - "{label}: sandbox {} ready, running command", - sandbox.name - )); - let output = sandbox.exec(command).await; - progress.step(format_args!("{label}: deleting sandbox {}", sandbox.name)); - sandbox.cleanup().await; - progress.step(format_args!("{label}: sandbox deleted")); - Ok(format!("{}{}", sandbox.create_output, output?)) -} - -async fn assert_gateway_admission( - progress: Progress, - provider: &str, - port: u16, - credential_source: CredentialSource, -) -> Result<(), String> { - let l4 = write_policy(port, EndpointMode::L4, credential_source, provider)?; - let l4_error = sandbox_create_failure(progress, "L4-only policy", provider, &l4).await; - assert!(l4_error.contains("credentialed endpoint"), "{l4_error}"); - assert!(l4_error.contains("L4-only"), "{l4_error}"); - - let tls_skip = write_policy(port, EndpointMode::TlsSkip, credential_source, provider)?; - let tls_error = sandbox_create_failure(progress, "tls: skip policy", provider, &tls_skip).await; - assert!(tls_error.contains("credentialed endpoint"), "{tls_error}"); - assert!( - tls_error.contains("tls:") && tls_error.contains("skip"), - "{tls_error}" - ); - - let opt_in = write_policy(port, EndpointMode::L4OptIn, credential_source, provider)?; - let accepted_marker = match credential_source { - CredentialSource::ProviderProfile => "OPT_IN_ACCEPTED", - CredentialSource::PolicyBinding => "ENDPOINTLESS_OPT_IN_ACCEPTED", - }; - let output = run_in_sandbox( - progress, - "opt-in policy", - &["--policy", utf8_path(&opt_in)?, "--provider", provider], - &["echo", accepted_marker], - ) - .await?; - assert!(output.contains(accepted_marker), "{output}"); - Ok(()) -} - -async fn wait_for_provider_key_presence( - sandbox: &SandboxGuard, - expected_present: bool, -) -> Result<(), String> { - let expected = if expected_present { - "TOKEN_PLACEHOLDER" - } else { - "TOKEN_ABSENT" - }; - let mut last_output = String::new(); - for _ in 0..60 { - last_output = sandbox.exec(&["sh", "-lc", KEY_PRESENCE_SCRIPT]).await?; - if last_output.contains(expected) { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(format!( - "provider environment did not converge to {expected}; last key-presence output:\n{last_output}" - )) -} - -async fn set_policy(progress: Progress, sandbox: &str, policy_path: &str) -> Result<(), String> { - let (success, output) = run_cli(&[ - "policy", - "set", - sandbox, - "--policy", - policy_path, - "--wait", - "--timeout", - "120", - ]) - .await; - if !success { - return Err(format!("policy update failed:\n{output}")); - } - progress.step("policy applied"); - Ok(()) -} - -async fn assert_endpointless_provider_env_live_update( - progress: Progress, - provider: &str, - port: u16, -) -> Result<(), String> { - let unbound = write_policy( - port, - EndpointMode::RestBody { rewrite: false }, - CredentialSource::ProviderProfile, - provider, - )?; - let bound = write_policy( - port, - EndpointMode::RestBody { rewrite: false }, - CredentialSource::PolicyBinding, - provider, - )?; - let unbound_path = utf8_path(&unbound)?; - let bound_path = utf8_path(&bound)?; - - progress.step("creating long-running sandbox with the unbound policy"); - let mut sandbox = SandboxGuard::create_keep_with_args( - &["--policy", unbound_path, "--provider", provider, "--no-tty"], - &["sh", "-c", "echo PROVIDER_UPDATE_READY && sleep infinity"], - "PROVIDER_UPDATE_READY", - ) - .await?; - - let result = async { - progress.step(format_args!( - "sandbox {} ready, waiting for the token to be absent", - sandbox.name - )); - wait_for_provider_key_presence(&sandbox, false).await?; - - progress.step("binding the provider with policy set --wait"); - set_policy(progress, &sandbox.name, bound_path) - .await - .map_err(|error| format!("binding {error}"))?; - progress.step("waiting for the token placeholder"); - wait_for_provider_key_presence(&sandbox, true).await?; - - progress.step("unbinding the provider with policy set --wait"); - set_policy(progress, &sandbox.name, unbound_path) - .await - .map_err(|error| format!("unbinding {error}"))?; - progress.step("waiting for the token to be absent"); - wait_for_provider_key_presence(&sandbox, false).await - } - .await; - - progress.step(format_args!("deleting sandbox {}", sandbox.name)); - sandbox.cleanup().await; - progress.step("sandbox deleted"); - result -} - -async fn run_body_sandbox( - progress: Progress, - provider: &str, - port: u16, - mode: EndpointMode, - credential_source: CredentialSource, -) -> Result { - let policy = write_policy(port, mode, credential_source, provider)?; - let script = body_client_script(port); - run_in_sandbox( - progress, - "REST body client", - &["--policy", utf8_path(&policy)?, "--provider", provider], - &["python3", "-c", &script], - ) - .await -} - -async fn assert_websocket_binary_denied( - progress: Progress, - provider: &str, - server: &BinaryWebSocketProbeServer, -) -> Result<(), String> { - let policy = write_policy( - server.port, - EndpointMode::WebSocket, - CredentialSource::ProviderProfile, - provider, - )?; - let script = binary_websocket_client_script(server.port); - let output = run_in_sandbox( - progress, - "WebSocket client", - &["--policy", utf8_path(&policy)?, "--provider", provider], - &["python3", "-c", &script], - ) - .await?; - assert!(output.contains("BINARY_DENIED"), "{output}"); - assert!( - server.wait_for_handshake().await, - "upstream should receive the WebSocket handshake" - ); - assert!( - !server.binary_seen.load(Ordering::Acquire), - "credentialed WebSocket binary frame reached upstream" - ); - Ok(()) -} - -/// Start both probe servers and install a provider whose profile declares -/// their endpoints. -async fn profile_endpoint_fixture( - progress: Progress, -) -> (HttpProbeServer, BinaryWebSocketProbeServer, GatingProvider) { - let server = HttpProbeServer::start().await.expect("start HTTP probe"); - let websocket_server = BinaryWebSocketProbeServer::start() - .await - .expect("start WebSocket probe"); - let provider = - GatingProvider::install_with_endpoints(progress, server.port, websocket_server.port) - .await - .expect("install credentialed provider"); - (server, websocket_server, provider) -} - -/// Start the HTTP probe server and install a provider whose profile declares -/// no endpoints, so policies must bind it explicitly. -async fn endpointless_fixture(progress: Progress) -> (HttpProbeServer, GatingProvider) { - let server = HttpProbeServer::start().await.expect("start HTTP probe"); - let provider = GatingProvider::install_endpointless(progress) - .await - .expect("install endpointless provider"); - (server, provider) -} - -#[tokio::test] -async fn profile_rest_endpoint_keeps_body_placeholder() { - let progress = Progress::start("profile-rest"); - let (server, _websocket_server, provider) = profile_endpoint_fixture(progress).await; - - let output = run_body_sandbox( - progress, - &provider.name, - server.port, - EndpointMode::RestBody { rewrite: false }, - CredentialSource::ProviderProfile, - ) - .await - .expect("run REST body client"); - assert!(output.contains("BODY_TEXT"), "{output}"); - let observations = server.wait_for_observations(1).await; - assert_eq!(observations.len(), 1, "observations: {observations:?}"); - assert!(observations[0].saw_placeholder); - assert!(!observations[0].saw_secret); - progress.step("checks passed"); -} - -#[tokio::test] -async fn profile_websocket_endpoint_denies_binary_frames() { - let progress = Progress::start("profile-websocket"); - let (_server, websocket_server, provider) = profile_endpoint_fixture(progress).await; - - assert_websocket_binary_denied(progress, &provider.name, &websocket_server) - .await - .expect("run WebSocket client"); - progress.step("checks passed"); -} - -#[tokio::test] -async fn policy_binding_requires_inspection_or_opt_in() { - let progress = Progress::start("binding-admission"); - let (server, provider) = endpointless_fixture(progress).await; - - assert_gateway_admission( - progress, - &provider.name, - server.port, - CredentialSource::PolicyBinding, - ) - .await - .expect("gateway admission"); - progress.step("checks passed"); -} - -#[tokio::test] -async fn policy_binding_rest_body_keeps_placeholder_without_rewrite() { - let progress = Progress::start("binding-body"); - let (server, provider) = endpointless_fixture(progress).await; - - let literal = run_body_sandbox( - progress, - &provider.name, - server.port, - EndpointMode::RestBody { rewrite: false }, - CredentialSource::PolicyBinding, - ) - .await - .expect("run REST body client"); - assert!(literal.contains("BODY_TEXT"), "{literal}"); - let observations = server.wait_for_observations(1).await; - assert_eq!(observations.len(), 1, "observations: {observations:?}"); - assert!(observations[0].saw_placeholder); - assert!(!observations[0].saw_secret); - progress.step("checks passed"); -} - -#[tokio::test] -async fn policy_binding_rest_body_rewrite_sends_secret_upstream() { - let progress = Progress::start("binding-rewrite"); - let (server, provider) = endpointless_fixture(progress).await; - - let rewritten = run_body_sandbox( - progress, - &provider.name, - server.port, - EndpointMode::RestBody { rewrite: true }, - CredentialSource::PolicyBinding, - ) - .await - .expect("run REST body client"); - assert!(rewritten.contains("BODY_REWRITTEN")); - assert!(!rewritten.contains(TEST_SECRET)); - assert!(!rewritten.contains(PLACEHOLDER_PREFIX)); - let observations = server.wait_for_observations(1).await; - assert_eq!(observations.len(), 1, "observations: {observations:?}"); - assert!(!observations[0].saw_placeholder); - assert!(observations[0].saw_secret); - progress.step("checks passed"); -} - -#[tokio::test] -async fn policy_binding_updates_provider_environment_live() { - let progress = Progress::start("binding-live-update"); - let (server, provider) = endpointless_fixture(progress).await; - - assert_endpointless_provider_env_live_update(progress, &provider.name, server.port) - .await - .expect("provider environment live update"); - progress.step("checks passed"); -} diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index ea04cd150d..29a6acdb75 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -493,6 +493,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -506,6 +516,7 @@ dependencies = [ name = "conformance" version = "0.0.0" dependencies = [ + "host-server-support", "openshell-testkit", "serde_json", "tempfile", @@ -561,6 +572,12 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1138,6 +1155,25 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -1165,6 +1201,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "host-server-support" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "openshell-testkit", + "sha1 0.10.6", + "tokio", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1518,6 +1564,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2021,6 +2116,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2109,6 +2208,19 @@ dependencies = [ "tower", ] +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "openshell-policy-schema", + "prost-types", + "serde", + "serde_json", +] + [[package]] name = "openshell-policy-schema" version = "0.0.0" @@ -2157,12 +2269,14 @@ dependencies = [ "linkme", "openshell-bootstrap", "openshell-core", + "openshell-policy", "openshell-sdk", "openshell-testkit-macros", "quick-junit", "rand 0.9.4", "serde", "serde_json", + "tempfile", "tokio", ] @@ -2337,6 +2451,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2755,6 +2875,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2837,6 +2966,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3083,6 +3221,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -3951,6 +4105,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4098,6 +4262,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index 99274d8dfa..7201953c9d 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -21,6 +21,7 @@ path = "src/main.rs" test = false [dependencies] +host-server-support = { path = "../../support/host-server" } openshell-testkit = { path = "../../testkit/testkit" } serde_json = "1" tempfile = "3" diff --git a/tests/suites/conformance/README.md b/tests/suites/conformance/README.md index c756107cc1..33cf17058e 100644 --- a/tests/suites/conformance/README.md +++ b/tests/suites/conformance/README.md @@ -14,6 +14,7 @@ nextest run the tests as the library's unit tests. The crate also builds the | `persistent-workspace` | Files in `/sandbox` survive stop and start | | `policy-advisor` | `policy.local` accepts a proposed rule; a denied address or hostname yields one draft rule | | `file-transfer` | `sandbox upload` and `download`: round trips, `.gitignore` filtering, workspace-escape and option-looking names | +| `credential-gating` | Provider credentials: admission of credentialed endpoints, REST body placeholders and rewriting, WebSocket binary frames, live policy binding | `smoke` is the smallest end-to-end check that the runner can run a real test in a tmachine environment on CI. The other suites are ports of the old @@ -21,7 +22,11 @@ tmachine environment on CI. The other suites are ports of the old (credited in each file). `file-transfer` runs `git` on the machine that runs the tests, so a test environment needs it installed (`tests/tmachine/ansible/playbooks/test-tools.yaml` does that for the tmachine -environments). +environments). `credential-gating`, ported from NVIDIA/OpenShell#5264, starts +servers (`tests/support/host-server`) on the machine that runs the tests and reaches them from sandboxes +created from `testkit::python_image()` through `host.openshell.internal`, so +it fails against a gateway whose sandboxes can't reach that machine, such as +a remote Kubernetes cluster. ## Running it diff --git a/tests/suites/conformance/src/credential_gating.rs b/tests/suites/conformance/src/credential_gating.rs new file mode 100644 index 0000000000..616eed1374 --- /dev/null +++ b/tests/suites/conformance/src/credential_gating.rs @@ -0,0 +1,375 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Credential gating: which endpoints a provider credential may reach, and +//! what the supervisor forwards upstream in REST bodies and WebSocket frames. +//! +//! Ported from the e2e tests added in NVIDIA/OpenShell#5264. The upstream +//! servers run on the test host ([`host_server_support`]), so a gateway on a +//! remote cluster can't run this suite. + +use std::time::Duration; + +use host_server_support::{HttpRecorder, WebSocketRecorder}; +use testkit::policy::{self, Access, Endpoint as _}; +use testkit::sdk::raw::proto; +use testkit::{Context, Error, PYTHON_BINARIES, Poll, Result, ResultExt as _, Sandbox, YamlFile}; + +const TEST_HOST: &str = "host.openshell.internal"; +const TOKEN_ENV: &str = "E2E_GATING_TOKEN"; +const TEST_SECRET: &str = "e2e-gating-secret-value"; +const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:"; + +/// POSTs a body containing the provider's token to `TEST_HOST` on the port +/// in `argv[1]`, directly rather than through a proxy. +const BODY_CLIENT: &str = r#" +import os, sys, urllib.request +body = ("prefix-" + os.environ["E2E_GATING_TOKEN"] + "-suffix").encode() +request = urllib.request.Request(f"http://host.openshell.internal:{sys.argv[1]}/token", data=body, headers={"Content-Type": "text/plain"}) +urllib.request.build_opener(urllib.request.ProxyHandler({})).open(request, timeout=10).read() +"#; + +/// Upgrades to WebSocket at `TEST_HOST` on the port in `argv[1]`, sends one +/// binary frame, and waits briefly for the connection to be closed. +const WEBSOCKET_CLIENT: &str = r#" +import base64, os, socket, sys +host, port = "host.openshell.internal", int(sys.argv[1]) +with socket.create_connection((host, port), timeout=10) as sock: + key = base64.b64encode(os.urandom(16)).decode() + sock.sendall(f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n".encode()) + response = b"" + while b"\r\n\r\n" not in response: + chunk = sock.recv(4096) + if not chunk: + sys.exit(f"closed during the upgrade: {response!r}") + response += chunk + if not response.startswith(b"HTTP/1.1 101"): + sys.exit(f"upgrade failed: {response!r}") + payload, mask = b"binary-credential-channel", os.urandom(4) + sock.sendall(bytes([0x82, 0x80 | len(payload)]) + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))) + sock.settimeout(3) + try: + sock.recv(4096) + except (TimeoutError, ConnectionError): + pass +"#; + +/// Prints `TOKEN_ABSENT`, `TOKEN_PLACEHOLDER`, or `TOKEN_UNSAFE` for the +/// provider's environment variable, without printing its value. +const TOKEN_PRESENCE: &str = r#"if [ "${E2E_GATING_TOKEN+x}" != x ]; then + echo TOKEN_ABSENT +else + case "$E2E_GATING_TOKEN" in + openshell:resolve:env:*) echo TOKEN_PLACEHOLDER ;; + *) echo TOKEN_UNSAFE ;; + esac +fi"#; + +/// A provider whose profile declares the REST and WebSocket servers as its +/// endpoints keeps the credential placeholder in a REST body. +#[testkit::test] +async fn profile_rest_endpoint_keeps_body_placeholder(cx: &mut Context) -> testkit::Result { + let server = HttpRecorder::start("ok").await?; + let websocket = WebSocketRecorder::start().await?; + let provider = install_provider(cx, Some((server.port, websocket.port))).await?; + + let mut policy = policy::base(); + allow(&mut policy, server.port) + .rest(Access::Full) + .request_body_credential_rewrite(false); + let sandbox = create_sandbox(cx, "rest", policy, &provider).await?; + run_python(&sandbox, BODY_CLIENT, server.port).await?; + expect_one_body(&server, PLACEHOLDER_PREFIX, TEST_SECRET).await +} + +/// A provider's WebSocket endpoint completes the handshake but doesn't +/// forward a binary frame, which the supervisor can't inspect for the +/// credential. +#[testkit::test] +async fn profile_websocket_endpoint_denies_binary_frames(cx: &mut Context) -> testkit::Result { + let server = HttpRecorder::start("ok").await?; + let websocket = WebSocketRecorder::start().await?; + let provider = install_provider(cx, Some((server.port, websocket.port))).await?; + + let mut policy = policy::base(); + allow(&mut policy, websocket.port).websocket(Access::ReadWrite); + let sandbox = create_sandbox(cx, "ws", policy, &provider).await?; + run_python(&sandbox, WEBSOCKET_CLIENT, websocket.port).await?; + websocket + .wait_for_handshake() + .await + .context("upstream should receive the WebSocket handshake")?; + testkit::ensure!( + !websocket.binary_seen(), + "credentialed WebSocket binary frame reached upstream" + ); + Ok(()) +} + +/// The gateway rejects a policy that binds a provider to an L4-only +/// endpoint or to an endpoint with `tls: skip`, and accepts an L4 endpoint +/// that opts in to uninspected credentials. +#[testkit::test] +async fn policy_binding_requires_inspection_or_opt_in(cx: &mut Context) -> testkit::Result { + let server = HttpRecorder::start("ok").await?; + let provider = install_provider(cx, None).await?; + + let mut l4 = policy::base(); + allow(&mut l4, server.port).credential_binding(&provider); + let error = expect_rejected(cx, "l4", l4, &provider).await?; + testkit::ensure!(error.contains("credentialed endpoint"), "{error}"); + testkit::ensure!(error.contains("L4-only"), "{error}"); + + let mut tls_skip = policy::base(); + allow(&mut tls_skip, server.port) + .rest(Access::Full) + .tls_skip() + .credential_binding(&provider); + let error = expect_rejected(cx, "tls", tls_skip, &provider).await?; + testkit::ensure!(error.contains("credentialed endpoint"), "{error}"); + testkit::ensure!(error.contains("tls:") && error.contains("skip"), "{error}"); + + let mut opt_in = policy::base(); + allow(&mut opt_in, server.port) + .allow_uninspected_credentials() + .credential_binding(&provider); + let sandbox = create_sandbox(cx, "optin", opt_in, &provider).await?; + sandbox.exec(["true"]).await?.success()?; + Ok(()) +} + +/// A policy binding to a REST endpoint without body rewriting sends the +/// credential placeholder upstream, not the secret. +#[testkit::test] +async fn policy_binding_rest_body_keeps_placeholder_without_rewrite( + cx: &mut Context, +) -> testkit::Result { + let server = HttpRecorder::start("ok").await?; + let provider = install_provider(cx, None).await?; + + let mut policy = policy::base(); + allow(&mut policy, server.port) + .rest(Access::Full) + .request_body_credential_rewrite(false) + .credential_binding(&provider); + let sandbox = create_sandbox(cx, "body", policy, &provider).await?; + run_python(&sandbox, BODY_CLIENT, server.port).await?; + expect_one_body(&server, PLACEHOLDER_PREFIX, TEST_SECRET).await +} + +/// A policy binding with `request_body_credential_rewrite: true` replaces +/// the placeholder in the REST body with the secret upstream. +#[testkit::test] +async fn policy_binding_rest_body_rewrite_sends_secret_upstream( + cx: &mut Context, +) -> testkit::Result { + let server = HttpRecorder::start("ok").await?; + let provider = install_provider(cx, None).await?; + + let mut policy = policy::base(); + allow(&mut policy, server.port) + .rest(Access::Full) + .request_body_credential_rewrite(true) + .credential_binding(&provider); + let sandbox = create_sandbox(cx, "rw", policy, &provider).await?; + run_python(&sandbox, BODY_CLIENT, server.port).await?; + expect_one_body(&server, TEST_SECRET, PLACEHOLDER_PREFIX).await +} + +/// Binding and unbinding a provider with `policy set --wait` adds and +/// removes its environment variable in a running sandbox. +#[testkit::test] +async fn policy_binding_updates_provider_environment_live(cx: &mut Context) -> testkit::Result { + let server = HttpRecorder::start("ok").await?; + let provider = install_provider(cx, None).await?; + + let policy_binding = |bind: bool| { + let mut policy = policy::base(); + let endpoint = allow(&mut policy, server.port) + .rest(Access::Full) + .request_body_credential_rewrite(false); + if bind { + endpoint.credential_binding(&provider); + } + policy + }; + let (unbound, bound) = (policy_binding(false), policy_binding(true)); + + let sandbox = create_sandbox(cx, "live", unbound.clone(), &provider).await?; + wait_for_token(&sandbox, "TOKEN_ABSENT").await?; + + set_policy(cx, &sandbox, &bound) + .await + .context("bind the provider")?; + wait_for_token(&sandbox, "TOKEN_PLACEHOLDER").await?; + + set_policy(cx, &sandbox, &unbound) + .await + .context("unbind the provider")?; + wait_for_token(&sandbox, "TOKEN_ABSENT").await +} + +/// Let the image's Python reach `TEST_HOST` on `port`. +fn allow(policy: &mut proto::SandboxPolicy, port: u16) -> &mut proto::NetworkEndpoint { + policy::allow( + policy, + "credential_gating", + TEST_HOST, + port, + PYTHON_BINARIES, + ) +} + +/// Install a run-unique provider holding `TEST_SECRET` in `TOKEN_ENV`. With +/// `endpoints`, its profile declares the REST and WebSocket ports as its +/// endpoints; without, a policy must bind the provider explicitly. +async fn install_provider(cx: &Context, endpoints: Option<(u16, u16)>) -> Result { + let name = cx.name("cg"); + let endpoints = endpoints.map_or_else(String::new, |(rest, websocket)| { + format!( + "endpoints: + - host: {TEST_HOST} + port: {rest} + protocol: rest + access: full + - host: {TEST_HOST} + port: {websocket} + protocol: websocket + access: read-write +" + ) + }); + let binaries: String = PYTHON_BINARIES + .iter() + .map(|path| format!(" - path: {path}\n")) + .collect(); + let profile = format!( + "id: {name} +display_name: E2E Credential Gating +category: other +credentials: + - name: token + env_vars: [{TOKEN_ENV}] + required: true + auth_style: bearer + header_name: authorization +{endpoints}binaries: +{binaries}" + ); + testkit::install_provider(cx, &name, &profile, &[(TOKEN_ENV, TEST_SECRET)]).await?; + Ok(name) +} + +fn sandbox_with( + cx: &Context, + suffix: &str, + policy: proto::SandboxPolicy, + provider: &str, +) -> testkit::SandboxBuilder { + cx.sandbox(suffix) + .policy(policy) + .spec(|spec| { + spec.image = Some(testkit::python_image()); + spec.providers = vec![provider.to_string()]; + }) + .command(["sleep", "infinity"]) +} + +/// Create a long-running sandbox from the Python image with `policy` and +/// `provider`, and wait until it is Ready. +async fn create_sandbox( + cx: &Context, + suffix: &str, + policy: proto::SandboxPolicy, + provider: &str, +) -> Result { + let sandbox = sandbox_with(cx, suffix, policy, provider).create().await?; + sandbox.wait_ready().await?; + Ok(sandbox) +} + +/// Fail unless the gateway rejects the sandbox, and return the rejection. +async fn expect_rejected( + cx: &Context, + suffix: &str, + policy: proto::SandboxPolicy, + provider: &str, +) -> Result { + match sandbox_with(cx, suffix, policy, provider).create().await { + Ok(sandbox) => Err(Error::msg(format!( + "the gateway accepted sandbox {}", + sandbox.name() + ))), + Err(error) => Ok(error.to_string()), + } +} + +/// Run `script` with the image's Python and `port` as its argument. +async fn run_python(sandbox: &Sandbox, script: &str, port: u16) -> Result { + let port = port.to_string(); + sandbox + .exec(["python3", "-c", script, &port]) + .await? + .success() + .context("run the client in the sandbox")?; + Ok(()) +} + +/// Fail unless `server` saw exactly one request, containing `present` and +/// not `absent`. +async fn expect_one_body(server: &HttpRecorder, present: &str, absent: &str) -> Result { + let requests = server.wait_for_requests(1).await?; + testkit::ensure!( + requests.len() == 1, + "expected one request upstream; got {}", + requests.len() + ); + let request = &requests[0]; + testkit::ensure!( + request.contains(present) && !request.contains(absent), + "expected {present:?} and not {absent:?} upstream" + ); + Ok(()) +} + +/// Wait until new exec sessions report `expected` from [`TOKEN_PRESENCE`]. +async fn wait_for_token(sandbox: &Sandbox, expected: &str) -> Result { + testkit::poll_until( + &format!("the provider environment to report {expected}"), + Duration::from_secs(30), + Duration::from_millis(500), + async || { + let output = sandbox.exec(["sh", "-lc", TOKEN_PRESENCE]).await?; + Ok(if output.stdout.contains(expected) { + Poll::Ready(()) + } else { + Poll::Pending(format!( + "exit {}; stdout: {}; stderr: {}", + output.exit_code, + output.stdout.trim(), + output.stderr.trim() + )) + }) + }, + ) + .await +} + +/// Replace `sandbox`'s policy with `policy` and wait until it is applied. +async fn set_policy(cx: &Context, sandbox: &Sandbox, policy: &proto::SandboxPolicy) -> Result { + let file = YamlFile::new(&policy::to_yaml(policy)?)?; + cx.cli([ + "policy", + "set", + sandbox.name(), + "--policy", + file.path()?, + "--wait", + "--timeout", + "120", + ]) + .await? + .success() + .context("policy set")?; + Ok(()) +} diff --git a/tests/suites/conformance/src/lib.rs b/tests/suites/conformance/src/lib.rs index 00a4d45027..98b916092f 100644 --- a/tests/suites/conformance/src/lib.rs +++ b/tests/suites/conformance/src/lib.rs @@ -9,6 +9,7 @@ //! program in `src/main.rs` runs them by linking this library. mod cli; +mod credential_gating; mod file_transfer; mod lifecycle; mod persistent_workspace; diff --git a/tests/suites/drivers/podman/Cargo.lock b/tests/suites/drivers/podman/Cargo.lock index 6633a3e703..d062100826 100644 --- a/tests/suites/drivers/podman/Cargo.lock +++ b/tests/suites/drivers/podman/Cargo.lock @@ -493,6 +493,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -560,6 +570,12 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1137,6 +1153,25 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -1517,6 +1552,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2020,6 +2104,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2108,6 +2196,19 @@ dependencies = [ "tower", ] +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "openshell-policy-schema", + "prost-types", + "serde", + "serde_json", +] + [[package]] name = "openshell-policy-schema" version = "0.0.0" @@ -2156,12 +2257,14 @@ dependencies = [ "linkme", "openshell-bootstrap", "openshell-core", + "openshell-policy", "openshell-sdk", "openshell-testkit-macros", "quick-junit", "rand 0.9.4", "serde", "serde_json", + "tempfile", "tokio", ] @@ -2353,6 +2456,12 @@ dependencies = [ "openshell-testkit", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2771,6 +2880,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2853,6 +2971,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3099,6 +3226,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -3967,6 +4110,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4114,6 +4267,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/tests/suites/features/Cargo.lock b/tests/suites/features/Cargo.lock index bb1a1427ee..6fbc0b4836 100644 --- a/tests/suites/features/Cargo.lock +++ b/tests/suites/features/Cargo.lock @@ -493,6 +493,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -560,6 +570,12 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1137,6 +1153,25 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -1168,8 +1203,17 @@ dependencies = [ name = "host-gateway" version = "0.0.0" dependencies = [ + "host-server-support", "openshell-testkit", - "tempfile", +] + +[[package]] +name = "host-server-support" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "openshell-testkit", + "sha1 0.10.6", "tokio", ] @@ -1526,6 +1570,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2057,6 +2150,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2145,6 +2242,19 @@ dependencies = [ "tower", ] +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "openshell-policy-schema", + "prost-types", + "serde", + "serde_json", +] + [[package]] name = "openshell-policy-schema" version = "0.0.0" @@ -2205,12 +2315,14 @@ dependencies = [ "linkme", "openshell-bootstrap", "openshell-core", + "openshell-policy", "openshell-sdk", "openshell-testkit-macros", "quick-junit", "rand 0.9.4", "serde", "serde_json", + "tempfile", "tokio", ] @@ -2385,6 +2497,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2803,6 +2921,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2885,6 +3012,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3131,6 +3267,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -3999,6 +4151,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4146,6 +4308,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/tests/suites/features/host-gateway/Cargo.toml b/tests/suites/features/host-gateway/Cargo.toml index d81b6cafb2..0dfef50bc5 100644 --- a/tests/suites/features/host-gateway/Cargo.toml +++ b/tests/suites/features/host-gateway/Cargo.toml @@ -12,6 +12,5 @@ repository = "https://github.com/NVIDIA/OpenShell" publish = false [dependencies] +host-server-support = { path = "../../../support/host-server" } openshell-testkit = { path = "../../../testkit/testkit" } -tempfile = "3" -tokio = { version = "1.43", features = ["io-util", "net", "rt"] } diff --git a/tests/suites/features/host-gateway/tests/host_gateway.rs b/tests/suites/features/host-gateway/tests/host_gateway.rs index 024b1cd84b..33269aa095 100644 --- a/tests/suites/features/host-gateway/tests/host_gateway.rs +++ b/tests/suites/features/host-gateway/tests/host_gateway.rs @@ -9,15 +9,15 @@ //! Python test image ([`testkit::python_image`]) that requests a page from that //! server through the name. -use testkit::{Context, Error, Result, ResultExt as _}; -use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::task::JoinHandle; +use host_server_support::HttpRecorder; +use testkit::policy; +use testkit::{Context, PYTHON_BINARIES, Result, ResultExt as _, YamlFile}; -/// The image's system Python, which needs nothing beyond its standard library. -/// The policy names its real path, `/usr/bin/python3.*`, because a rule matches -/// the executable behind a symlink. -const PYTHON: &str = "/usr/bin/python3"; +/// The image's Python, which needs nothing beyond its standard library. The +/// policy allows [`PYTHON_BINARIES`], the real paths behind `python3` in +/// `testkit::python_image()` images, because a rule matches the executable +/// behind a symlink. +const PYTHON: &str = "python3"; /// Requests `/` from `host.openshell.internal` on the port in `argv[1]` and /// prints the response. A failed connection is retried for 20 seconds, so a @@ -51,8 +51,8 @@ print(response.decode(errors="replace")) #[testkit::test] async fn python_sandbox_reaches_host_server(cx: &mut Context) -> testkit::Result { let marker = format!("host-gateway-{}", cx.run_id()); - let server = HostServer::start(marker.clone()).await?; - let policy = PolicyFile::new(&policy(server.port))?; + let server = HttpRecorder::start(marker.clone()).await?; + let policy = YamlFile::new(&policy(server.port)?)?; let port = server.port.to_string(); let image = testkit::python_image(); @@ -100,110 +100,18 @@ async fn python_sandbox_reaches_host_server(cx: &mut Context) -> testkit::Result Ok(()) } -/// A sandbox policy that allows the image's system Python ([`PYTHON`]) to +/// A sandbox policy that allows the image's Python ([`PYTHON`]) to /// connect to `host.openshell.internal` on `port`. The endpoint names only the /// host and port, as the policy docs describe, so the driver's address for the /// name is what makes it reachable, not an `allowed_ips` range. -fn policy(port: u16) -> String { - format!( - r#"version: 1 - -filesystem_policy: - include_workdir: true - read_only: [/usr, /lib, /lib64, /proc, /dev/urandom, /etc] - read_write: [/sandbox, /tmp, /dev/null] -landlock: - compatibility: best_effort - -process: - run_as_user: sandbox - run_as_group: sandbox - -network_policies: - host_server: - name: host_server - endpoints: - - host: host.openshell.internal - port: {port} - binaries: - - path: /usr/bin/python3.* -"# - ) -} - -/// An HTTP server on the host that answers every request with `body`. It -/// stops when dropped. -struct HostServer { - port: u16, - task: JoinHandle<()>, -} - -impl HostServer { - /// Listen on every host address, since drivers map - /// `host.openshell.internal` to different ones. - async fn start(body: String) -> Result { - let listener = TcpListener::bind(("0.0.0.0", 0)) - .await - .map_err(|error| Error::from_error(&error)) - .context("bind the host server")?; - let port = listener - .local_addr() - .map_err(|error| Error::from_error(&error)) - .context("read the host server's address")? - .port(); - let task = tokio::spawn(async move { - while let Ok((stream, _)) = listener.accept().await { - tokio::spawn(respond(stream, body.clone())); - } - }); - Ok(Self { port, task }) - } -} - -impl Drop for HostServer { - fn drop(&mut self) { - self.task.abort(); - } -} - -/// Read one request's headers from `stream` and answer with `body`. -async fn respond(mut stream: TcpStream, body: String) { - let _ = stream.set_nodelay(true); - let mut request = Vec::new(); - let mut buf = [0_u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - match stream.read(&mut buf).await { - Ok(read) if read > 0 && request.len() < 64 * 1024 => { - request.extend_from_slice(&buf[..read]); - } - _ => return, - } - } - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() +fn policy(port: u16) -> Result { + let mut policy = policy::base(); + policy::allow( + &mut policy, + "host_server", + "host.openshell.internal", + port, + PYTHON_BINARIES, ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; -} - -/// A sandbox policy written to a temporary file for `sandbox create --policy`. -struct PolicyFile(tempfile::NamedTempFile); - -impl PolicyFile { - fn new(contents: &str) -> Result { - let file = tempfile::NamedTempFile::new() - .map_err(|error| Error::from_error(&error)) - .context("create policy file")?; - std::fs::write(file.path(), contents) - .map_err(|error| Error::from_error(&error)) - .context(format!("write {}", file.path().display()))?; - Ok(Self(file)) - } - - fn path(&self) -> Result<&str> { - let path = self.0.path(); - path.to_str() - .ok_or_else(|| Error::msg(format!("{} is not UTF-8", path.display()))) - } + policy::to_yaml(&policy) } diff --git a/tests/support/Cargo.lock b/tests/support/Cargo.lock index f8b591438f..cd15939875 100644 --- a/tests/support/Cargo.lock +++ b/tests/support/Cargo.lock @@ -493,6 +493,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -560,6 +570,12 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1137,6 +1153,25 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -1164,6 +1199,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "host-server-support" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "openshell-testkit", + "sha1 0.10.6", + "tokio", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1517,6 +1562,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2029,6 +2123,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2117,6 +2215,19 @@ dependencies = [ "tower", ] +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "openshell-policy-schema", + "prost-types", + "serde", + "serde_json", +] + [[package]] name = "openshell-policy-schema" version = "0.0.0" @@ -2165,12 +2276,14 @@ dependencies = [ "linkme", "openshell-bootstrap", "openshell-core", + "openshell-policy", "openshell-sdk", "openshell-testkit-macros", "quick-junit", "rand 0.9.4", "serde", "serde_json", + "tempfile", "tokio", ] @@ -2353,6 +2466,12 @@ dependencies = [ "openshell-testkit", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2771,6 +2890,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2853,6 +2981,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3099,6 +3236,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -3967,6 +4120,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4114,6 +4277,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/tests/support/Cargo.toml b/tests/support/Cargo.toml index f56ee275f5..4c7c495f97 100644 --- a/tests/support/Cargo.toml +++ b/tests/support/Cargo.toml @@ -5,4 +5,4 @@ # environment runs. Suites depend on them by path. [workspace] resolver = "2" -members = ["container-engine", "keycloak", "podman"] +members = ["container-engine", "host-server", "keycloak", "podman"] diff --git a/tests/support/host-server/Cargo.toml b/tests/support/host-server/Cargo.toml new file mode 100644 index 0000000000..aa964e782d --- /dev/null +++ b/tests/support/host-server/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "host-server-support" +description = "Servers on the test host that sandboxes reach through host.openshell.internal" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +base64 = "0.22" +openshell-testkit = { path = "../../testkit/testkit" } +sha1 = "0.10" +tokio = { version = "1.43", features = ["io-util", "net", "rt", "time"] } diff --git a/tests/support/host-server/src/lib.rs b/tests/support/host-server/src/lib.rs new file mode 100644 index 0000000000..f5d1f6522f --- /dev/null +++ b/tests/support/host-server/src/lib.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Servers on the test host that sandboxes reach through +//! `host.openshell.internal`, and that record what arrived, so a test can +//! check what the supervisor forwarded upstream. +//! +//! The servers listen on every host address, since drivers map +//! `host.openshell.internal` to different ones, and stop when dropped. They +//! run where the tests run, so a gateway whose sandboxes can't reach the test +//! host, such as one on a remote cluster, can't run tests that use them. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use base64::Engine as _; +use sha1::{Digest as _, Sha1}; +use testkit::{Error, Poll, Result, ResultExt as _}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinHandle; + +const WAIT_TIMEOUT: Duration = Duration::from_secs(10); +const WAIT_INTERVAL: Duration = Duration::from_millis(100); +const READ_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_REQUEST: usize = 1024 * 1024; + +/// An HTTP server that answers every request with `200` and a fixed body, +/// and records each request it read. +pub struct HttpRecorder { + pub port: u16, + requests: Arc>>, + task: JoinHandle<()>, +} + +impl HttpRecorder { + pub async fn start(body: impl Into) -> Result { + let (listener, port) = bind().await?; + let body: Arc = body.into().into(); + let requests = Arc::new(Mutex::new(Vec::new())); + let task_requests = Arc::clone(&requests); + let task = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(respond( + stream, + Arc::clone(&body), + Arc::clone(&task_requests), + )); + } + }); + Ok(Self { + port, + requests, + task, + }) + } + + /// The requests the server has read, headers and body, once there are at + /// least `count`. + pub async fn wait_for_requests(&self, count: usize) -> Result> { + testkit::poll_until( + &format!( + "{count} request(s) at the host server on port {}", + self.port + ), + WAIT_TIMEOUT, + WAIT_INTERVAL, + async || { + let requests = self + .requests + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + Ok(if requests.len() >= count { + Poll::Ready(requests) + } else { + Poll::Pending(format!("{} request(s)", requests.len())) + }) + }, + ) + .await + } +} + +impl Drop for HttpRecorder { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// A WebSocket server that completes the handshake and records whether a +/// binary frame followed. +pub struct WebSocketRecorder { + pub port: u16, + handshake_seen: Arc, + binary_seen: Arc, + task: JoinHandle<()>, +} + +impl WebSocketRecorder { + pub async fn start() -> Result { + let (listener, port) = bind().await?; + let handshake_seen = Arc::new(AtomicBool::new(false)); + let binary_seen = Arc::new(AtomicBool::new(false)); + let (task_handshake, task_binary) = (Arc::clone(&handshake_seen), Arc::clone(&binary_seen)); + let task = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(accept_websocket( + stream, + Arc::clone(&task_handshake), + Arc::clone(&task_binary), + )); + } + }); + Ok(Self { + port, + handshake_seen, + binary_seen, + task, + }) + } + + /// Wait until a client completed the handshake. + pub async fn wait_for_handshake(&self) -> Result { + testkit::poll_until( + &format!( + "a WebSocket handshake at the host server on port {}", + self.port + ), + WAIT_TIMEOUT, + WAIT_INTERVAL, + async || { + Ok(if self.handshake_seen.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + Poll::Pending("no handshake".to_string()) + }) + }, + ) + .await + } + + /// Whether a binary frame arrived after a handshake. + pub fn binary_seen(&self) -> bool { + self.binary_seen.load(Ordering::Acquire) + } +} + +impl Drop for WebSocketRecorder { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn bind() -> Result<(TcpListener, u16)> { + let listener = TcpListener::bind(("0.0.0.0", 0)) + .await + .map_err(|error| Error::from_error(&error)) + .context("bind a host server")?; + let port = listener + .local_addr() + .map_err(|error| Error::from_error(&error)) + .context("read the host server's address")? + .port(); + Ok((listener, port)) +} + +/// Read one request, headers and `Content-Length` body, record it, and +/// answer with `body`. +async fn respond(mut stream: TcpStream, body: Arc, requests: Arc>>) { + let _ = stream.set_nodelay(true); + let Some(request) = read_request(&mut stream).await else { + return; + }; + requests + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(String::from_utf8_lossy(&request).into_owned()); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; +} + +async fn read_request(stream: &mut TcpStream) -> Option> { + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + if let Some(end) = find(&request, b"\r\n\r\n") { + let length = header(&String::from_utf8_lossy(&request[..end]), "content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if request.len() >= end + 4 + length { + return Some(request); + } + } + match tokio::time::timeout(READ_TIMEOUT, stream.read(&mut buffer)).await { + Ok(Ok(read)) if read > 0 && request.len() < MAX_REQUEST => { + request.extend_from_slice(&buffer[..read]); + } + _ => return None, + } + } +} + +async fn accept_websocket( + mut stream: TcpStream, + handshake_seen: Arc, + binary_seen: Arc, +) { + let _ = stream.set_nodelay(true); + let Some(request) = read_request(&mut stream).await else { + return; + }; + let Some(key) = header(&String::from_utf8_lossy(&request), "sec-websocket-key") else { + return; + }; + // The supervisor checks Sec-WebSocket-Accept, so compute it properly. + let mut hasher = Sha1::new(); + hasher.update(key.as_bytes()); + hasher.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + let accept = base64::engine::general_purpose::STANDARD.encode(hasher.finalize()); + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ); + if stream.write_all(response.as_bytes()).await.is_err() { + return; + } + handshake_seen.store(true, Ordering::Release); + + let mut frame_header = [0_u8; 2]; + if let Ok(Ok(_)) = + tokio::time::timeout(READ_TIMEOUT, stream.read_exact(&mut frame_header)).await + && frame_header[0] & 0x0f == 0x02 + { + binary_seen.store(true, Ordering::Release); + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn header(head: &str, name: &str) -> Option { + head.lines().find_map(|line| { + let (header, value) = line.split_once(':')?; + header + .trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) +} diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index db2e79b6ca..383f9f2454 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -493,6 +493,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -551,6 +561,12 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1128,6 +1144,25 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -1508,6 +1543,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2011,6 +2095,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2099,6 +2187,19 @@ dependencies = [ "tower", ] +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "openshell-policy-schema", + "prost-types", + "serde", + "serde_json", +] + [[package]] name = "openshell-policy-schema" version = "0.0.0" @@ -2147,6 +2248,7 @@ dependencies = [ "linkme", "openshell-bootstrap", "openshell-core", + "openshell-policy", "openshell-sdk", "openshell-testkit-macros", "quick-junit", @@ -2329,6 +2431,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2747,6 +2855,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2829,6 +2946,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3075,6 +3201,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -3943,6 +4085,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4090,6 +4242,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/tests/testkit/README.md b/tests/testkit/README.md index c02ad01c25..b89a726cdf 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -65,7 +65,8 @@ fails. A killed test process skips cleanup, so names are unique to the run. - `cx.sandbox(suffix)` to build and create a sandbox with a run-unique name. The returned `Sandbox` can wait for ready or finished, stop, start, exec, - read its config and draft policy, set a setting, and delete. + read its config and draft policy, set a setting, and delete. `exec` output + has `success()`. - `cx.track(name)` to register a sandbox created some other way, such as through the CLI, for cleanup. Call it before the create, so a create that fails after the gateway accepted it is still cleaned @@ -75,14 +76,24 @@ fails. A killed test process skips cleanup, so names are unique to the run. compute driver. Driver suites call it first. - `testkit::base_image()` for the image to create sandboxes from or build on: `OPENSHELL_TEST_BASE_IMAGE`, or OpenShell's default sandbox image. -- `testkit::python_image()` for sandboxes that run `python3`, curl, git, or - openssl: `OPENSHELL_TEST_PYTHON_IMAGE`, or `openshell/e2e-python:dev` - (`e2e/python/Dockerfile.workload`). The tmachine installers load it into - every environment; `mise run e2e:workload:build` builds it locally. +- `testkit::python_image()` for sandboxes that run `python3`: + `OPENSHELL_TEST_PYTHON_IMAGE`, or a pinned public uv Python image that any + gateway can pull. It has the standard library only, not curl, git, or + openssl. - `cx.cli([...])` to run the installed CLI against the same gateway, with `success()`, `failure()`, and `json()` on the output. `cx.cli_handle()` returns that CLI for code that can't borrow `cx`, such as a cleanup hook. - `cx.on_cleanup(name, hook)` to remove something when the test ends. +- `testkit::policy` for sandbox policies: `policy::base()` reads + `testkit/src/base-policy.yaml`, which allows no network access, and + `policy::allow(&mut policy, name, host, port, binaries)` adds an endpoint + that `policy::Endpoint` configures (`.rest(Access::Full)`, + `.credential_binding(provider)`, ...). Pass the result to + `cx.sandbox(..).policy(policy)`, or `policy::to_yaml` for the CLI. + `testkit::PYTHON_BINARIES` names `python3` in `python_image()` images. +- `testkit::install_provider(cx, name, profile, credentials)` to import a + provider profile and create a provider of it, with cleanup for both. +- `testkit::YamlFile` for YAML a CLI command reads from a file. - `cx.client()` for SDK calls testkit does not wrap. - `poll_until` for waiting on other state. diff --git a/tests/testkit/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml index 47ff56c13c..288e11990d 100644 --- a/tests/testkit/testkit/Cargo.toml +++ b/tests/testkit/testkit/Cargo.toml @@ -17,6 +17,7 @@ name = "testkit" [dependencies] openshell-bootstrap = { path = "../../../crates/openshell-bootstrap" } openshell-core = { path = "../../../crates/openshell-core", default-features = false } +openshell-policy = { path = "../../../crates/openshell-policy" } openshell-sdk = { path = "../../../crates/openshell-sdk" } openshell-testkit-macros = { path = "../testkit-macros" } clap = { version = "4", features = ["derive"] } @@ -27,13 +28,13 @@ quick-junit = "0.8" rand = "0.9" serde = "1" serde_json = "1" +tempfile = "3" tokio = { version = "1.43", features = ["macros", "process", "rt", "time"] } [target.'cfg(unix)'.dependencies] libc = "0.2" [dev-dependencies] -tempfile = "3" tonic = "0.14" # A test program, run by `cargo test` and nextest through `testkit::main`'s diff --git a/tests/testkit/testkit/src/base-policy.yaml b/tests/testkit/testkit/src/base-policy.yaml new file mode 100644 index 0000000000..19f77d006f --- /dev/null +++ b/tests/testkit/testkit/src/base-policy.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# The sandbox policy tests start from (testkit::policy::base()): the image's +# system directories read-only, the sandbox's home and /tmp writable, and no +# network access. Tests add the network access they need with +# testkit::policy::allow(). It names no workload user, so the driver runs the +# image's non-root user, or a default non-root user when the image has none. +version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /lib64, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index ea20878837..8709db8a18 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -358,26 +358,10 @@ impl SandboxBuilder { // The SDK's spec has no policy field, so create through gRPC. Some(policy) => { crate::ensure!( - self.spec.image.is_none() - && self.spec.environment.is_empty() - && self.spec.providers.is_empty() - && !self.spec.gpu - && self.spec.service_exposures.is_empty(), - "testkit creates a sandbox with a policy from its name, labels, command, restart policy, and policy only" + !self.spec.gpu && self.spec.service_exposures.is_empty(), + "testkit doesn't create a sandbox with a policy and a GPU or service exposures" ); - let request = proto::CreateSandboxRequest { - name: name.clone(), - labels: self.spec.labels, - workspace_scope: self.grpc.scope(), - spec: Some(proto::SandboxSpec { - command: self.spec.command, - tty: self.spec.tty, - policy: Some(policy), - restart_policy: restart_policy(self.spec.restart_policy) as i32, - ..proto::SandboxSpec::default() - }), - ..proto::CreateSandboxRequest::default() - }; + let request = policy_create_request(self.spec, policy, self.grpc.scope()); self.grpc .client() .await? @@ -402,6 +386,34 @@ impl SandboxBuilder { } } +/// The gRPC request for a sandbox created from `spec` with `policy`, built +/// as the SDK builds one from `spec` alone. +fn policy_create_request( + spec: SandboxSpec, + policy: proto::SandboxPolicy, + workspace_scope: Option, +) -> proto::CreateSandboxRequest { + proto::CreateSandboxRequest { + name: spec.name.unwrap_or_default(), + labels: spec.labels, + workspace_scope, + spec: Some(proto::SandboxSpec { + environment: spec.environment, + template: spec.image.map(|image| proto::SandboxTemplate { + image, + ..proto::SandboxTemplate::default() + }), + providers: spec.providers, + command: spec.command, + tty: spec.tty, + policy: Some(policy), + restart_policy: restart_policy(spec.restart_policy) as i32, + ..proto::SandboxSpec::default() + }), + ..proto::CreateSandboxRequest::default() + } +} + const fn restart_policy(policy: SandboxRestartPolicy) -> proto::SandboxRestartPolicy { match policy { SandboxRestartPolicy::Never => proto::SandboxRestartPolicy::Never, @@ -427,6 +439,20 @@ pub struct ExecOutput { pub stderr: String, } +impl ExecOutput { + /// Fail unless the command exited 0. + pub fn success(self) -> Result { + crate::ensure!( + self.exit_code == 0, + "exec exited with {}; stdout: {}; stderr: {}", + self.exit_code, + self.stdout.trim(), + self.stderr.trim() + ); + Ok(self) + } +} + impl Sandbox { pub fn name(&self) -> &str { &self.name @@ -631,3 +657,26 @@ fn generate_run_id() -> String { .map(|_| char::from(ALPHABET[rng.random_range(0..ALPHABET.len())])) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_create_request_keeps_the_spec_and_adds_the_policy() { + let spec = SandboxSpec { + image: Some("python:3".to_string()), + providers: vec!["provider".to_string()], + ..SandboxSpec::default() + }; + let policy = crate::policy::base(); + + let spec = policy_create_request(spec, policy.clone(), None) + .spec + .expect("spec"); + + assert_eq!(spec.template.expect("template").image, "python:3"); + assert_eq!(spec.providers, ["provider"]); + assert_eq!(spec.policy, Some(policy)); + } +} diff --git a/tests/testkit/testkit/src/image.rs b/tests/testkit/testkit/src/image.rs index 26bb2278fe..67f6a78ae0 100644 --- a/tests/testkit/testkit/src/image.rs +++ b/tests/testkit/testkit/src/image.rs @@ -11,11 +11,19 @@ const DEFAULT_BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; /// Overrides [`DEFAULT_PYTHON_IMAGE`]. const PYTHON_IMAGE_ENV: &str = "OPENSHELL_TEST_PYTHON_IMAGE"; -/// The workload image for tests that run `python3`, curl, git or openssl in a -/// sandbox, built from `e2e/python/Dockerfile.workload`. It isn't published: -/// the tmachine installers load it into every environment, and -/// `mise run e2e:workload:build` builds it locally. -const DEFAULT_PYTHON_IMAGE: &str = "openshell/e2e-python:dev"; +/// The image for tests that run `python3` in a sandbox. It is public and +/// pinned by digest so any gateway can pull it, and has the Python standard +/// library only: no curl, git or openssl. +const DEFAULT_PYTHON_IMAGE: &str = "ghcr.io/astral-sh/uv:0.12.17-python3.12-trixie-slim@sha256:9a59bb7206905ccaae4f7dab222fbac47c125a21e5fc16f43f427cd6c940ade3"; + +/// The paths `python3` runs from in [`python_image`] images, as network +/// policy binaries: the system Python, the pullable uv image's Python, and the +/// workload image's uv-installed Python behind its virtualenv. +pub const PYTHON_BINARIES: &[&str] = &[ + "/usr/bin/python*", + "/usr/local/bin/python*", + "/sandbox/.uv/python/*/bin/python*", +]; /// The image named by [`BASE_IMAGE_ENV`], or [`DEFAULT_BASE_IMAGE`]. pub fn base_image() -> String { diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index 834f28c1e7..2786668d9b 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -32,11 +32,14 @@ mod context; mod error; pub mod gateway; mod image; +pub mod policy; mod poll; pub mod program; +mod provider; mod ready; mod registry; mod run; +mod yaml_file; pub use cleanup::CLEANUP_TIMEOUT; pub use cli::{CLI_TIMEOUT, Cli, CliOutput}; @@ -46,14 +49,16 @@ pub use context::{ }; pub use error::{Error, Result, ResultExt}; pub use gateway::Gateway; -pub use image::{base_image, python_image}; +pub use image::{PYTHON_BINARIES, base_image, python_image}; pub use openshell_sdk as sdk; pub use poll::{Poll, poll_until}; pub use program::main; +pub use provider::install_provider; pub use ready::check as check_ready; pub use registry::Test; pub use run::{TestInfo, run}; pub use testkit_macros::test; +pub use yaml_file::YamlFile; #[doc(hidden)] pub mod __private { diff --git a/tests/testkit/testkit/src/policy.rs b/tests/testkit/testkit/src/policy.rs new file mode 100644 index 0000000000..454cc4fb52 --- /dev/null +++ b/tests/testkit/testkit/src/policy.rs @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox policies for tests: [`base`], read from `base-policy.yaml`, plus +//! [`allow`] and the [`Endpoint`] settings for the network access a test +//! needs. +//! +//! ```ignore +//! let mut policy = testkit::policy::base(); +//! testkit::policy::allow(&mut policy, "upstream", "host.openshell.internal", port, PYTHON_BINARIES) +//! .rest(Access::Full) +//! .credential_binding(&provider); +//! ``` + +use openshell_sdk::raw::proto; + +use crate::error::{Error, Result, ResultExt as _}; + +pub use proto::NetworkAccessPreset as Access; + +const BASE: &str = include_str!("base-policy.yaml"); + +/// The policy in `base-policy.yaml`. It allows no network access. +pub fn base() -> proto::SandboxPolicy { + openshell_policy::parse_sandbox_policy(BASE).expect("base-policy.yaml is a valid policy") +} + +/// Let `binaries` connect to `host:port` under the network policy `name`, +/// adding the policy if it doesn't exist. Returns the new endpoint, which +/// [`Endpoint`] configures further. +pub fn allow<'a>( + policy: &'a mut proto::SandboxPolicy, + name: &str, + host: &str, + port: u16, + binaries: &[&str], +) -> &'a mut proto::NetworkEndpoint { + let rule = policy + .network_policies + .entry(name.to_string()) + .or_insert_with(|| proto::NetworkPolicyRule { + name: name.to_string(), + ..proto::NetworkPolicyRule::default() + }); + for binary in binaries { + if !rule + .binaries + .iter() + .any(|existing| existing.path == *binary) + { + rule.binaries.push(proto::NetworkBinary { + path: (*binary).to_string(), + }); + } + } + rule.endpoints.push(proto::NetworkEndpoint { + host: host.to_string(), + port: port.into(), + ports: vec![port.into()], + ..proto::NetworkEndpoint::default() + }); + rule.endpoints + .last_mut() + .expect("an endpoint was just added") +} + +/// The policy as YAML, for CLI commands such as `policy set`. +pub fn to_yaml(policy: &proto::SandboxPolicy) -> Result { + openshell_policy::serialize_sandbox_policy(policy) + .map_err(|error| Error::msg(format!("{error:?}"))) + .context("serialize the policy") +} + +/// Settings for an endpoint from [`allow`], named as in policy YAML. +pub trait Endpoint { + /// `protocol: rest` with `access`. + fn rest(&mut self, access: Access) -> &mut Self; + /// `protocol: websocket` with `access`. + fn websocket(&mut self, access: Access) -> &mut Self; + /// `tls: skip`. + fn tls_skip(&mut self) -> &mut Self; + /// `request_body_credential_rewrite`. + fn request_body_credential_rewrite(&mut self, rewrite: bool) -> &mut Self; + /// `credential_binding: {provider: }`. + fn credential_binding(&mut self, provider: &str) -> &mut Self; + /// `allow_uninspected_credentials: true`. + fn allow_uninspected_credentials(&mut self) -> &mut Self; +} + +impl Endpoint for proto::NetworkEndpoint { + fn rest(&mut self, access: Access) -> &mut Self { + self.protocol = "rest".to_string(); + self.access = access as i32; + self + } + + fn websocket(&mut self, access: Access) -> &mut Self { + self.protocol = "websocket".to_string(); + self.access = access as i32; + self + } + + fn tls_skip(&mut self) -> &mut Self { + self.tls = proto::NetworkTlsMode::Skip as i32; + self + } + + fn request_body_credential_rewrite(&mut self, rewrite: bool) -> &mut Self { + self.request_body_credential_rewrite = rewrite; + self + } + + fn credential_binding(&mut self, provider: &str) -> &mut Self { + self.credential_binding = Some(proto::NetworkCredentialBinding { + provider: provider.to_string(), + }); + self + } + + fn allow_uninspected_credentials(&mut self) -> &mut Self { + self.allow_uninspected_credentials = true; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each helper builds what the YAML it is named after parses to, so the + /// gateway can't tell the two apart. + #[test] + fn helpers_match_the_yaml_they_name() { + let mut policy = base(); + allow( + &mut policy, + "upstream", + "example.test", + 8080, + &["/usr/bin/python*"], + ) + .rest(Access::Full) + .tls_skip() + .request_body_credential_rewrite(true) + .credential_binding("provider"); + allow( + &mut policy, + "upstream", + "example.test", + 8081, + &["/usr/bin/python*"], + ) + .websocket(Access::ReadWrite) + .allow_uninspected_credentials(); + + let yaml = format!( + "{BASE}network_policies: + upstream: + name: upstream + endpoints: + - host: example.test + port: 8080 + protocol: rest + access: full + tls: skip + request_body_credential_rewrite: true + credential_binding: {{provider: provider}} + - host: example.test + port: 8081 + protocol: websocket + access: read-write + allow_uninspected_credentials: true + binaries: + - path: /usr/bin/python* +" + ); + let expected = openshell_policy::parse_sandbox_policy(&yaml).expect("the YAML parses"); + assert_eq!(policy, expected); + let round_trip = + openshell_policy::parse_sandbox_policy(&to_yaml(&policy).expect("serializes")) + .expect("the serialized policy parses"); + assert_eq!(round_trip, expected); + } +} diff --git a/tests/testkit/testkit/src/provider.rs b/tests/testkit/testkit/src/provider.rs new file mode 100644 index 0000000000..c87fc6c51c --- /dev/null +++ b/tests/testkit/testkit/src/provider.rs @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use crate::context::Context; +use crate::error::{Result, ResultExt as _}; +use crate::yaml_file::YamlFile; + +/// Import `profile`, a provider profile whose `id` is `name`, and create a +/// provider called `name` of that type with `credentials`, as pairs of +/// environment variable and value. Cleanup deletes the provider, then the +/// profile, after the test's sandboxes. +/// +/// The CLI reads the credential values from its environment, so they aren't +/// in the reported command. +pub async fn install_provider( + cx: &Context, + name: &str, + profile: &str, + credentials: &[(&str, &str)], +) -> Result { + // Both deletes succeed for a missing resource, so register them before + // the creates. Hooks run last registered first. + for kind in ["profile", "provider"] { + let cli = cx.cli_handle(); + let owned = name.to_string(); + cx.on_cleanup(format!("delete {kind} {name}"), async move || { + cli.run([kind, "delete", &owned]).await?.success()?; + Ok(()) + }); + } + + let file = YamlFile::new(profile)?; + cx.cli(["profile", "import", "--file", file.path()?]) + .await? + .success() + .context(format!("import profile {name}"))?; + let mut args = vec!["provider", "create", "--name", name, "--type", name]; + for (key, _) in credentials { + args.extend(["--credential", key]); + } + cx.cli_with_env(credentials, args) + .await? + .success() + .context(format!("create provider {name}"))?; + Ok(()) +} diff --git a/tests/testkit/testkit/src/yaml_file.rs b/tests/testkit/testkit/src/yaml_file.rs new file mode 100644 index 0000000000..04ca3d815d --- /dev/null +++ b/tests/testkit/testkit/src/yaml_file.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::{Error, Result, ResultExt as _}; + +/// YAML in a temporary file, for CLI commands that read one, such as +/// `sandbox create --policy` or `profile import --file`. The file is removed +/// when dropped. +pub struct YamlFile(tempfile::NamedTempFile); + +impl YamlFile { + pub fn new(contents: &str) -> Result { + let file = tempfile::Builder::new() + .suffix(".yaml") + .tempfile() + .map_err(|error| Error::from_error(&error)) + .context("create a temporary YAML file")?; + std::fs::write(file.path(), contents) + .map_err(|error| Error::from_error(&error)) + .context(format!("write {}", file.path().display()))?; + Ok(Self(file)) + } + + /// The file's path, as a CLI argument. + pub fn path(&self) -> Result<&str> { + let path = self.0.path(); + path.to_str() + .ok_or_else(|| Error::msg(format!("{} is not UTF-8", path.display()))) + } +}