From 0bbf50673b1b9fc7977ef03f51601c0e7c8246d4 Mon Sep 17 00:00:00 2001 From: jamesboyzj-design Date: Thu, 1 Oct 2026 02:35:28 +0800 Subject: [PATCH 1/2] fix(tls): generate MITM certificates accepted by strict X509 clients Set leaf Authority Key Identifier and CA keyCertSign/cRLSign usages. Add a real local HTTPS probe with Python strict verification and synthetic credential injection; do not weaken client verification. --- crates/sandlock-cli/tests/strict_tls.py | 74 +++++++++++++++++++ crates/sandlock-cli/tests/strict_tls.rs | 18 +++++ .../sandlock-core/src/transparent_proxy/ca.rs | 3 +- .../src/transparent_proxy/tls.rs | 3 + 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 crates/sandlock-cli/tests/strict_tls.py create mode 100644 crates/sandlock-cli/tests/strict_tls.rs diff --git a/crates/sandlock-cli/tests/strict_tls.py b/crates/sandlock-cli/tests/strict_tls.py new file mode 100644 index 00000000..d893a31a --- /dev/null +++ b/crates/sandlock-cli/tests/strict_tls.py @@ -0,0 +1,74 @@ +"""Linux integration probe: openssl + Python stdlib, strict verification enabled.""" +import http.server +import os +from pathlib import Path +import ssl +import subprocess +import sys +import tempfile +import threading + + +def openssl(*args): + subprocess.run(['openssl', *args], check=True, capture_output=True, timeout=15) + + +def main(binary): + with tempfile.TemporaryDirectory(prefix='sandlock-strict-tls-') as directory: + root = Path(directory) + ca, key, leaf, leafkey, csr = (root / n for n in ('ca.pem', 'ca.key', 'leaf.pem', 'leaf.key', 'leaf.csr')) + openssl('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key), + '-out', str(ca), '-days', '1', '-subj', '/CN=Test upstream CA') + openssl('req', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(leafkey), + '-out', str(csr), '-subj', '/CN=localhost') + ext = root / 'leaf.ext' + ext.write_text('subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n' + 'extendedKeyUsage=serverAuth\nauthorityKeyIdentifier=keyid,issuer\n') + openssl('x509', '-req', '-in', str(csr), '-CA', str(ca), '-CAkey', str(key), + '-CAcreateserial', '-out', str(leaf), '-days', '1', '-extfile', str(ext)) + received = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + received.append(self.headers.get('Authorization') == 'Bearer synthetic-tls-probe') + self.send_response(200) + self.send_header('Content-Length', '2') + self.end_headers() + self.wfile.write(b'ok') + + def log_message(*args): + pass + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(leaf, leafkey) + server.socket = context.wrap_socket(server.socket, server_side=True) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + args = [binary, 'run', '--timeout', '10', '--clean-env'] + for path in ('/usr', '/bin', '/lib', '/lib64', '/etc'): + if Path(path).exists(): + args += ['-r', path] + args += ['--http-allow', 'GET localhost/check', '--http-port', str(server.server_port), + '--http-inject-ca', '/etc/ssl/certs/ca-certificates.crt', + '--credential', 'test=env:SL_TEST_CREDENTIAL', + '--http-auth', 'GET localhost/check bearer test', '--', 'python3', '-c', + 'import os,ssl,urllib.request;' + 'assert "SL_TEST_CREDENTIAL" not in os.environ;' + 'ctx=ssl.create_default_context(cafile="/etc/ssl/certs/ca-certificates.crt");' + 'ctx.verify_flags |= ssl.VERIFY_X509_STRICT;' + f'assert urllib.request.urlopen("https://localhost:{server.server_port}/check",' + 'context=ctx,timeout=5).read()==b"ok"'] + env = {'PATH': '/usr/bin:/bin', 'HOME': str(Path.home()), 'SSL_CERT_FILE': str(ca), + 'SL_TEST_CREDENTIAL': 'synthetic-tls-probe'} + result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=20) + assert result.returncode == 0, result.stderr + assert received == [True], received + print('PASS: strict TLS verified; synthetic credential received by real HTTPS server') + finally: + server.shutdown() + server.server_close() + + +if __name__ == '__main__': + main(sys.argv[1]) diff --git a/crates/sandlock-cli/tests/strict_tls.rs b/crates/sandlock-cli/tests/strict_tls.rs new file mode 100644 index 00000000..2dd4a60b --- /dev/null +++ b/crates/sandlock-cli/tests/strict_tls.rs @@ -0,0 +1,18 @@ +use std::path::Path; +use std::process::Command; + +#[test] +fn strict_python_tls_uses_generated_ca_and_leaf() { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/strict_tls.py"); + let out = Command::new("python3") + .arg(script) + .arg(env!("CARGO_BIN_EXE_sandlock")) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/crates/sandlock-core/src/transparent_proxy/ca.rs b/crates/sandlock-core/src/transparent_proxy/ca.rs index a11c07ad..888791cb 100644 --- a/crates/sandlock-core/src/transparent_proxy/ca.rs +++ b/crates/sandlock-core/src/transparent_proxy/ca.rs @@ -8,13 +8,14 @@ use rcgen::{CertificateParams, KeyPair}; /// Pre-generated dummy CA for HTTP-only mode, avoiding per-spawn keygen cost. fn dummy_ca() -> std::io::Result<(KeyPair, rcgen::Certificate)> { - use rcgen::{BasicConstraints, DnType, IsCa}; + use rcgen::{BasicConstraints, DnType, IsCa, KeyUsagePurpose}; let kp = KeyPair::generate().map_err(|e| { std::io::Error::new(std::io::ErrorKind::Other, format!("keygen failed: {e}")) })?; let mut params = CertificateParams::default(); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; // A distinct subject DN is required: leaf certs minted under this CA must // have a subject that differs from their issuer, otherwise an empty-DN leaf // looks self-signed (subject == issuer) and clients reject it. diff --git a/crates/sandlock-core/src/transparent_proxy/tls.rs b/crates/sandlock-core/src/transparent_proxy/tls.rs index 280e3e77..e06ac87f 100644 --- a/crates/sandlock-core/src/transparent_proxy/tls.rs +++ b/crates/sandlock-core/src/transparent_proxy/tls.rs @@ -47,6 +47,9 @@ impl CertSigner { // Give the leaf a subject CN distinct from the CA's subject, so the leaf // is not mistaken for self-signed (subject == issuer) by clients. params.distinguished_name.push(DnType::CommonName, sni); + // RFC 5280 4.2.1.1: non-self-signed certificates identify their issuer's + // key. OpenSSL strict verification (Python 3.13+) requires this. + params.use_authority_key_identifier_extension = true; // signed_by(public_key, issuer_cert, issuer_key): leaf public key is the // leaf KeyPair (impl PublicKeyData), signed by the CA cert + CA key. let leaf = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key).map_err(|e| { From c7c4f1237f4e25a42adcb2e81c89b342798e6cd2 Mon Sep 17 00:00:00 2001 From: jamesboyzj-design Date: Sat, 3 Oct 2026 00:24:33 +0800 Subject: [PATCH 2/2] test(tls): move strict X509 regression coverage into core Exercise Sandbox MITM with an injected temporary CA bundle and assert the exact local HTTP ACL denial. Add production-minted certificate AKI/SKI and CA keyCertSign assertions using x509-parser. Validated on Linux ARM64 with Python 3.13.3/OpenSSL 3.4.1, including independent AKI and key-usage red/green mutations. Focused tests pass. The full workspace suite reproduces the same four failures as pristine PR head; repository-wide formatting also has baseline failures. --- Cargo.lock | 1 + crates/sandlock-cli/tests/strict_tls.py | 74 ----------- crates/sandlock-cli/tests/strict_tls.rs | 18 --- crates/sandlock-core/Cargo.toml | 1 + .../src/transparent_proxy/tls.rs | 118 ++++++++++++++---- crates/sandlock-core/tests/integration.rs | 3 + .../tests/integration/test_http_strict_tls.rs | 77 ++++++++++++ 7 files changed, 177 insertions(+), 115 deletions(-) delete mode 100644 crates/sandlock-cli/tests/strict_tls.py delete mode 100644 crates/sandlock-cli/tests/strict_tls.rs create mode 100644 crates/sandlock-core/tests/integration/test_http_strict_tls.rs diff --git a/Cargo.lock b/Cargo.lock index 5ad94d59..adc1000f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,6 +1414,7 @@ dependencies = [ "toml", "uuid", "walkdir", + "x509-parser", ] [[package]] diff --git a/crates/sandlock-cli/tests/strict_tls.py b/crates/sandlock-cli/tests/strict_tls.py deleted file mode 100644 index d893a31a..00000000 --- a/crates/sandlock-cli/tests/strict_tls.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Linux integration probe: openssl + Python stdlib, strict verification enabled.""" -import http.server -import os -from pathlib import Path -import ssl -import subprocess -import sys -import tempfile -import threading - - -def openssl(*args): - subprocess.run(['openssl', *args], check=True, capture_output=True, timeout=15) - - -def main(binary): - with tempfile.TemporaryDirectory(prefix='sandlock-strict-tls-') as directory: - root = Path(directory) - ca, key, leaf, leafkey, csr = (root / n for n in ('ca.pem', 'ca.key', 'leaf.pem', 'leaf.key', 'leaf.csr')) - openssl('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key), - '-out', str(ca), '-days', '1', '-subj', '/CN=Test upstream CA') - openssl('req', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(leafkey), - '-out', str(csr), '-subj', '/CN=localhost') - ext = root / 'leaf.ext' - ext.write_text('subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n' - 'extendedKeyUsage=serverAuth\nauthorityKeyIdentifier=keyid,issuer\n') - openssl('x509', '-req', '-in', str(csr), '-CA', str(ca), '-CAkey', str(key), - '-CAcreateserial', '-out', str(leaf), '-days', '1', '-extfile', str(ext)) - received = [] - - class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - received.append(self.headers.get('Authorization') == 'Bearer synthetic-tls-probe') - self.send_response(200) - self.send_header('Content-Length', '2') - self.end_headers() - self.wfile.write(b'ok') - - def log_message(*args): - pass - - server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(leaf, leafkey) - server.socket = context.wrap_socket(server.socket, server_side=True) - threading.Thread(target=server.serve_forever, daemon=True).start() - try: - args = [binary, 'run', '--timeout', '10', '--clean-env'] - for path in ('/usr', '/bin', '/lib', '/lib64', '/etc'): - if Path(path).exists(): - args += ['-r', path] - args += ['--http-allow', 'GET localhost/check', '--http-port', str(server.server_port), - '--http-inject-ca', '/etc/ssl/certs/ca-certificates.crt', - '--credential', 'test=env:SL_TEST_CREDENTIAL', - '--http-auth', 'GET localhost/check bearer test', '--', 'python3', '-c', - 'import os,ssl,urllib.request;' - 'assert "SL_TEST_CREDENTIAL" not in os.environ;' - 'ctx=ssl.create_default_context(cafile="/etc/ssl/certs/ca-certificates.crt");' - 'ctx.verify_flags |= ssl.VERIFY_X509_STRICT;' - f'assert urllib.request.urlopen("https://localhost:{server.server_port}/check",' - 'context=ctx,timeout=5).read()==b"ok"'] - env = {'PATH': '/usr/bin:/bin', 'HOME': str(Path.home()), 'SSL_CERT_FILE': str(ca), - 'SL_TEST_CREDENTIAL': 'synthetic-tls-probe'} - result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=20) - assert result.returncode == 0, result.stderr - assert received == [True], received - print('PASS: strict TLS verified; synthetic credential received by real HTTPS server') - finally: - server.shutdown() - server.server_close() - - -if __name__ == '__main__': - main(sys.argv[1]) diff --git a/crates/sandlock-cli/tests/strict_tls.rs b/crates/sandlock-cli/tests/strict_tls.rs deleted file mode 100644 index 2dd4a60b..00000000 --- a/crates/sandlock-cli/tests/strict_tls.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::path::Path; -use std::process::Command; - -#[test] -fn strict_python_tls_uses_generated_ca_and_leaf() { - let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/strict_tls.py"); - let out = Command::new("python3") - .arg(script) - .arg(env!("CARGO_BIN_EXE_sandlock")) - .output() - .unwrap(); - assert!( - out.status.success(), - "{}\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); -} diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index 08c1dfda..a45faebf 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -46,3 +46,4 @@ cli = ["dep:clap"] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } tempfile = "3" rustls-pemfile = "2" +x509-parser = "0.16" diff --git a/crates/sandlock-core/src/transparent_proxy/tls.rs b/crates/sandlock-core/src/transparent_proxy/tls.rs index e06ac87f..6d1f3c5b 100644 --- a/crates/sandlock-core/src/transparent_proxy/tls.rs +++ b/crates/sandlock-core/src/transparent_proxy/tls.rs @@ -29,7 +29,11 @@ impl CertSigner { let ca_cert = ca_params.self_signed(&ca_key).map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, format!("CA rebuild: {e}")) })?; - Ok(Self { ca_cert, ca_key, cache: Mutex::new(HashMap::new()) }) + Ok(Self { + ca_cert, + ca_key, + cache: Mutex::new(HashMap::new()), + }) } /// Mint (or cache-hit) a ServerConfig presenting a leaf cert for `sni`. @@ -37,24 +41,7 @@ impl CertSigner { if let Some(cfg) = self.cache.lock().unwrap().get(sni) { return Ok(Arc::clone(cfg)); } - let leaf_key = KeyPair::generate().map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, format!("leaf keygen: {e}")) - })?; - // new(vec![sni]) sets subject_alt_names to a single DnsName(sni) entry. - let mut params = CertificateParams::new(vec![sni.to_string()]).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("leaf params: {e}")) - })?; - // Give the leaf a subject CN distinct from the CA's subject, so the leaf - // is not mistaken for self-signed (subject == issuer) by clients. - params.distinguished_name.push(DnType::CommonName, sni); - // RFC 5280 4.2.1.1: non-self-signed certificates identify their issuer's - // key. OpenSSL strict verification (Python 3.13+) requires this. - params.use_authority_key_identifier_extension = true; - // signed_by(public_key, issuer_cert, issuer_key): leaf public key is the - // leaf KeyPair (impl PublicKeyData), signed by the CA cert + CA key. - let leaf = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, format!("leaf sign: {e}")) - })?; + let (leaf, leaf_key) = self.mint_leaf(sni)?; // Present only the leaf; the CA is the trust anchor in the client's store. let chain = vec![leaf.der().clone()]; @@ -66,26 +53,111 @@ impl CertSigner { let provider = Arc::new(rustls::crypto::ring::default_provider()); let mut cfg = ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")))? + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")) + })? .with_no_client_auth() .with_single_cert(chain, key_der) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")))?; + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")) + })?; cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; let cfg = Arc::new(cfg); - self.cache.lock().unwrap().insert(sni.to_string(), Arc::clone(&cfg)); + self.cache + .lock() + .unwrap() + .insert(sni.to_string(), Arc::clone(&cfg)); Ok(cfg) } + + fn mint_leaf(&self, sni: &str) -> std::io::Result<(Certificate, KeyPair)> { + let leaf_key = KeyPair::generate().map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("leaf keygen: {e}")) + })?; + // new(vec![sni]) sets subject_alt_names to a single DnsName(sni) entry. + let mut params = CertificateParams::new(vec![sni.to_string()]).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("leaf params: {e}"), + ) + })?; + // Give the leaf a subject CN distinct from the CA's subject, so the leaf + // is not mistaken for self-signed (subject == issuer) by clients. + params.distinguished_name.push(DnType::CommonName, sni); + // RFC 5280 4.2.1.1: non-self-signed certificates identify their issuer's + // key. OpenSSL strict verification (Python 3.13+) requires this. + params.use_authority_key_identifier_extension = true; + // signed_by(public_key, issuer_cert, issuer_key): leaf public key is the + // leaf KeyPair (impl PublicKeyData), signed by the CA cert + CA key. + let leaf = params + .signed_by(&leaf_key, &self.ca_cert, &self.ca_key) + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("leaf sign: {e}")) + })?; + + Ok((leaf, leaf_key)) + } } #[cfg(test)] mod tests { use super::*; + use x509_parser::extensions::ParsedExtension; + use x509_parser::prelude::parse_x509_certificate; fn test_ca() -> (String, String) { - let m = crate::transparent_proxy::resolve_ca(None, None, true).unwrap().unwrap(); + let m = crate::transparent_proxy::resolve_ca(None, None, true) + .unwrap() + .unwrap(); (m.cert_pem, m.key_pem) } + #[test] + fn generated_ca_has_key_cert_sign() { + let (cert, _) = test_ca(); + let (_, pem) = x509_parser::pem::parse_x509_pem(cert.as_bytes()).expect("CA PEM"); + let (_, ca) = parse_x509_certificate(&pem.contents).expect("CA DER"); + let usage = ca + .key_usage() + .expect("valid CA key usage") + .expect("CA KeyUsage required"); + assert!( + usage.value.key_cert_sign(), + "CA KeyUsage must include keyCertSign" + ); + } + + #[test] + fn minted_leaf_aki_matches_generated_ca_ski() { + let (cert, key) = test_ca(); + let signer = CertSigner::new(&cert, &key).expect("signer"); + let (leaf, _) = signer.mint_leaf("localhost").expect("minted leaf"); + let (_, pem) = x509_parser::pem::parse_x509_pem(cert.as_bytes()).expect("CA PEM"); + let (_, ca) = parse_x509_certificate(&pem.contents).expect("CA DER"); + let (_, leaf) = parse_x509_certificate(leaf.der()).expect("leaf DER"); + let ski = ca + .extensions() + .iter() + .find_map(|ext| match ext.parsed_extension() { + ParsedExtension::SubjectKeyIdentifier(ski) => Some(ski.0), + _ => None, + }) + .expect("CA SubjectKeyIdentifier required"); + let aki = leaf + .extensions() + .iter() + .find_map(|ext| match ext.parsed_extension() { + ParsedExtension::AuthorityKeyIdentifier(aki) => Some(aki), + _ => None, + }) + .expect("leaf AuthorityKeyIdentifier required"); + let key_id = aki + .key_identifier + .as_ref() + .expect("leaf AKI keyIdentifier required"); + assert_eq!(key_id.0, ski, "leaf AKI must identify the original CA SKI"); + } + #[test] fn mints_distinct_configs_per_sni() { let (cert, key) = test_ca(); diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index 753ca102..d21c1a63 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -73,6 +73,9 @@ mod test_protection; #[path = "integration/test_http_inject_ca.rs"] mod test_http_inject_ca; +#[path = "integration/test_http_strict_tls.rs"] +mod test_http_strict_tls; + #[path = "integration/test_restore.rs"] mod test_restore; diff --git a/crates/sandlock-core/tests/integration/test_http_strict_tls.rs b/crates/sandlock-core/tests/integration/test_http_strict_tls.rs new file mode 100644 index 00000000..186ceb86 --- /dev/null +++ b/crates/sandlock-core/tests/integration/test_http_strict_tls.rs @@ -0,0 +1,77 @@ +use sandlock_core::Sandbox; +use std::time::Duration; + +/// Strict verification must complete before the proxy denies the HTTP path. +/// localhost resolves without public DNS and passes the proxy's Host/IP check; +/// no upstream listener is needed because the ACL rejects before forwarding. +#[tokio::test] +async fn strict_x509_reaches_http_acl_denial() { + let dir = tempfile::tempdir().expect("temporary trust bundle directory"); + let bundle = dir.path().join("bundle.pem"); + std::fs::write(&bundle, b"").expect("create empty trust bundle"); + let path = std::env::var_os("PATH").expect("PATH to locate python3"); + let python_dir = std::env::split_paths(&path) + .find(|dir| dir.join("python3").is_file()) + .expect("python3 installed on PATH"); + + let mut sandbox = Sandbox::builder() + .fs_read("/usr") + .fs_read("/lib") + .fs_read_if_exists("/lib64") + .fs_read("/bin") + .fs_read("/etc") + .fs_read("/proc") + .fs_read("/dev") + .fs_read(&python_dir) + .fs_read(dir.path()) + .clean_env(true) + .env_var("PATH", python_dir.to_str().expect("UTF-8 Python directory")) + .http_allow("GET localhost/allowed") + .http_inject_ca(&bundle) + .build() + .expect("strict TLS sandbox policy"); + + let script = r#" +import ssl +import sys +import urllib.error +import urllib.request + +context = ssl.create_default_context(cafile=sys.argv[1]) +context.verify_flags |= ssl.VERIFY_X509_STRICT +assert context.verify_mode == ssl.CERT_REQUIRED +assert context.check_hostname +opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=context), +) +try: + with opener.open('https://localhost/denied', timeout=10) as response: + raise AssertionError(f'expected ACL denial, got {response.status}') +except urllib.error.HTTPError as error: + with error: + assert error.code == 403, f'expected 403, got {error.code}' + body = error.read() + assert body == b'Blocked by sandlock HTTP ACL policy', repr(body) + print('strict X509 verified; HTTP 403: Blocked by sandlock HTTP ACL policy') +"#; + let result = tokio::time::timeout( + Duration::from_secs(30), + sandbox.run(&["python3", "-c", script, bundle.to_str().unwrap()]), + ) + .await + .expect("strict TLS sandbox must finish within 30 seconds") + .expect("run strict TLS sandbox"); + assert!( + result.success(), + "strict TLS client status={:?}\nstdout={}\nstderr={}", + result.code(), + String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()), + String::from_utf8_lossy(result.stderr.as_deref().unwrap_or_default()), + ); + assert_eq!( + std::fs::read(&bundle).unwrap(), + b"", + "host bundle unchanged" + ); +}