//! Corporate CA certificate injection. //! //! Users behind a TLS-terminating corporate proxy need their organisation's //! root CA inside every container, or **every** HTTPS call fails — npm, pip, //! git, curl, the Playwright browser, and Claude Code's own API calls. //! //! The mechanism follows the SSH/AWS host-mount pattern in [`super::container`]: //! a host path is bind-mounted **read-only** into the container and //! `entrypoint.sh` applies it on every start. That is what makes it durable //! across container recreation, base-image migration and Reset — a certificate //! installed by hand inside a running container is lost the first time any of //! those happen. //! //! ## Two things that are easy to get wrong //! //! 1. **`update-ca-certificates` only reads `*.crt`.** It globs //! `/usr/local/share/ca-certificates/*.crt` case-sensitively, so a `.pem` //! (the far more common export format) that is merely *copied* in is //! silently ignored — no warning, no error, just a container that still //! cannot speak HTTPS. Certificates must be **renamed**, which is what //! [`container_cert_name`] does. //! //! 2. **The system trust store is not enough.** Only curl/git/apt read it. //! Node — and therefore Claude Code itself — needs `NODE_EXTRA_CA_CERTS`, //! Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`, and //! Chrome/Chromium read neither: they have their own NSS database at //! `~/.pki/nssdb`, seeded by `certutil` in the entrypoint. //! //! ## Why the env vars are set from Rust and not exported by the entrypoint //! //! An `export` in `entrypoint.sh` reaches only the entrypoint's own children. //! Every terminal session is a separate `docker exec`, which inherits the //! *container's* configured env and sees nothing the entrypoint exported — //! the same lesson that forced `$BROWSER` to become an image-level `ENV` for //! the URL relay shim. Since the bundle path written by //! `update-ca-certificates` is deterministic ([`CA_BUNDLE_PATH`]), Rust can set //! all three vars at container creation, where `docker exec` will see them. use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; /// Where the host's CA material is bind-mounted, read-only. Mirrors /// `/tmp/.host-ssh` and `/tmp/.host-aws`. /// /// A *directory* on the host is mounted here as-is. A single *file* is mounted /// at `/` — Docker creates the parent — so the /// entrypoint only ever has to deal with a directory, and the certificate keeps /// a recognisable name instead of becoming the literal path `.host-ca`. pub const CA_MOUNT_DIR: &str = "/tmp/.host-ca"; /// The concatenated PEM bundle `update-ca-certificates` writes on /// Debian/Ubuntu. Deterministic, which is what lets the env vars below be set /// at container-creation time, before the entrypoint has run. pub const CA_BUNDLE_PATH: &str = "/etc/ssl/certs/ca-certificates.crt"; /// Consulted by Node — and therefore by Claude Code itself, which is the whole /// reason this feature exists. pub const NODE_EXTRA_CA_CERTS: &str = "NODE_EXTRA_CA_CERTS"; /// Consulted by `requests` (and so by pip's vendored copy). pub const REQUESTS_CA_BUNDLE: &str = "REQUESTS_CA_BUNDLE"; /// Consulted by OpenSSL, and so by Python's `ssl` module. pub const SSL_CERT_FILE: &str = "SSL_CERT_FILE"; /// Every env var this module owns, in a fixed order. /// /// Also the list that must be *cleared* when no CA is configured: `docker /// commit` bakes a container's env into the project's snapshot image, and /// create-time env replaces image `ENV` per key — so without an explicit empty /// value, removing the setting would leave the vars live in every future /// container. Empty is safe for all three (verified on Ubuntu 24.04: curl, /// `openssl s_client` and Python's `ssl` all behave exactly as they do with the /// variable unset). pub const CA_ENV_KEYS: &[&str] = &[NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, SSL_CERT_FILE]; /// Extensions treated as certificates when the configured path is a directory. /// Matched case-insensitively. DER is deliberately absent — the system store /// and every consumer here want PEM. const CERT_EXTENSIONS: &[&str] = &["crt", "pem", "cer", "cert", "ca-bundle"]; /// A configured CA path that has been checked and resolved into everything the /// container creation path needs. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedCa { /// The host path, as configured. pub host_path: String, /// Whether the host path is a directory (as opposed to a single file). pub is_dir: bool, /// The bind-mount target inside the container. pub mount_target: String, /// The certificate files found, sorted. pub cert_files: Vec, } fn sha256_hex(input: &str) -> String { let mut hasher = Sha256::new(); hasher.update(input.as_bytes()); format!("{:x}", hasher.finalize()) } /// The file name a certificate is installed as under /// `/usr/local/share/ca-certificates/`. /// /// `update-ca-certificates` globs `*.crt` **case-sensitively**, so `.pem`, /// `.cer`, `.CRT` and extension-less files all have to end up as a lowercase /// `.crt` or they are ignored without a word. Characters outside /// `[A-Za-z0-9._-]` are replaced so that whitespace cannot break the shell /// loops that walk the store, and leading dots are stripped so a hidden file /// does not stay hidden. /// /// `entrypoint.sh` reimplements exactly this in a few lines of shell (it has to /// rename the files inside the container); the two must agree, which is what /// the unit tests below pin down. pub fn container_cert_name(file_name: &str) -> String { let sanitized: String = file_name .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' { c } else { '_' } }) .collect(); let sanitized = sanitized.trim_start_matches('.'); // Strip one trailing extension, whatever it is, then force `.crt`. A name // with no dot keeps its whole self as the stem. let stem = match sanitized.rfind('.') { Some(i) => &sanitized[..i], None => sanitized, }; let stem = if stem.is_empty() { "corporate-ca" } else { stem }; format!("{}.crt", stem) } /// Whether a directory entry looks like a certificate worth installing. fn is_cert_file(path: &Path) -> bool { let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false; }; let ext = ext.to_ascii_lowercase(); CERT_EXTENSIONS.contains(&ext.as_str()) } /// The certificate files a configured path contributes. /// /// A file is taken at face value — the user pointed at it explicitly, so its /// extension is not second-guessed. A directory is scanned one level deep /// (matching the entrypoint's `find -maxdepth 1`) and filtered by extension, /// so an `openssl.cnf` or a README sitting next to the certs is skipped. /// The result is sorted, so the fingerprint is stable across filesystem /// enumeration order. pub fn collect_cert_files(path: &Path) -> Vec { if path.is_file() { return vec![path.to_path_buf()]; } if !path.is_dir() { return Vec::new(); } let Ok(entries) = std::fs::read_dir(path) else { return Vec::new(); }; let mut files: Vec = entries .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.is_file() && is_cert_file(p)) .collect(); files.sort(); files } /// Resolve the configured CA path, or explain why it cannot be used. /// /// `Ok(None)` means "no CA configured", which is the overwhelmingly common /// case and must stay free. An `Err` aborts the container start: behind a /// TLS-intercepting proxy a container without the CA is broken in a dozen /// confusing ways, so naming the bad path once is far kinder than letting npm, /// pip and Claude Code each fail their own way. pub fn resolve(path: Option<&str>) -> Result, String> { let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else { return Ok(None); }; let root = Path::new(raw); if !root.exists() { return Err(format!( "Corporate CA certificate path '{}' does not exist. Update it in \ Settings → Certificates, or clear this project's override in \ Project Home → Config → Access.", raw )); } let is_dir = root.is_dir(); if !is_dir && !root.is_file() { return Err(format!( "Corporate CA certificate path '{}' is neither a file nor a directory.", raw )); } let cert_files = collect_cert_files(root); if cert_files.is_empty() { return Err(format!( "Corporate CA certificate directory '{}' contains no certificate files \ (looked for {} one level deep).", raw, CERT_EXTENSIONS .iter() .map(|e| format!(".{}", e)) .collect::>() .join(", ") )); } let mount_target = if is_dir { CA_MOUNT_DIR.to_string() } else { let name = root .file_name() .map(|n| container_cert_name(&n.to_string_lossy())) .unwrap_or_else(|| "corporate-ca.crt".to_string()); format!("{}/{}", CA_MOUNT_DIR, name) }; Ok(Some(ResolvedCa { host_path: raw.to_string(), is_dir, mount_target, cert_files, })) } /// Fingerprint of the CA configuration, for the `triple-c.ca-fingerprint` /// label. /// /// `container_needs_recreation` is label-based and never diffs env or mounts, /// so without this, changing the CA path would silently do nothing until some /// unrelated setting forced a rebuild. /// /// It covers **both** the resolved path *and the bytes of every certificate*, /// because replacing a rotated CA at the same path is at least as common as /// moving it — and the container's copy is made once, at start, so nothing else /// would notice. /// /// Never returns an error: a path that has gone missing hashes differently from /// one that is present, which is exactly the "something changed, recreate" /// signal wanted here. Reporting the problem is [`resolve`]'s job. pub fn compute_ca_fingerprint(path: Option<&str>) -> String { let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else { return String::new(); }; let mut parts: Vec = vec![raw.to_string()]; let root = Path::new(raw); if !root.exists() { parts.push("".to_string()); } else { for file in collect_cert_files(root) { let name = file .file_name() .map(|n| container_cert_name(&n.to_string_lossy())) .unwrap_or_default(); let digest = match std::fs::read(&file) { Ok(bytes) => { let mut hasher = Sha256::new(); hasher.update(&bytes); format!("{:x}", hasher.finalize()) } Err(_) => "".to_string(), }; parts.push(format!("{}:{}", name, digest)); } } sha256_hex(&parts.join("|")) } /// The env vars to set on the container. /// /// Always returns all of [`CA_ENV_KEYS`]: pointing at the bundle when a CA is /// configured, empty when it is not. The empty case is not cosmetic — see the /// note on [`CA_ENV_KEYS`]. pub fn ca_env_vars(resolved: Option<&ResolvedCa>) -> Vec<(&'static str, String)> { let value = if resolved.is_some() { CA_BUNDLE_PATH } else { "" }; CA_ENV_KEYS .iter() .map(|key| (*key, value.to_string())) .collect() } #[cfg(test)] mod tests { use super::*; use std::fs; /// A scratch directory that cleans itself up. `tempfile` is not a /// dependency of this crate and this is the only test that needs one. struct TempDir(PathBuf); impl TempDir { fn new(tag: &str) -> Self { let mut p = std::env::temp_dir(); p.push(format!( "triple-c-ca-test-{}-{}-{:?}", tag, std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&p).unwrap(); TempDir(p) } fn path(&self) -> &Path { &self.0 } fn write(&self, name: &str, contents: &str) -> PathBuf { let p = self.0.join(name); fs::write(&p, contents).unwrap(); p } } impl Drop for TempDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } // ── container_cert_name ──────────────────────────────────────────────── #[test] fn a_pem_is_renamed_to_crt_not_merely_copied() { // The whole point: update-ca-certificates globs *.crt and would // silently ignore corp-root.pem. assert_eq!(container_cert_name("corp-root.pem"), "corp-root.crt"); } #[test] fn a_crt_keeps_its_name() { assert_eq!(container_cert_name("corp-root.crt"), "corp-root.crt"); } #[test] fn other_certificate_extensions_are_renamed_too() { assert_eq!(container_cert_name("zscaler.cer"), "zscaler.crt"); assert_eq!(container_cert_name("zscaler.cert"), "zscaler.crt"); assert_eq!(container_cert_name("bundle.ca-bundle"), "bundle.crt"); } #[test] fn an_uppercase_extension_is_lowercased() { // `find -name '*.crt'` is case-sensitive, so CA.CRT would be ignored. assert_eq!(container_cert_name("CA.CRT"), "CA.crt"); assert_eq!(container_cert_name("CA.PEM"), "CA.crt"); } #[test] fn a_name_without_an_extension_gains_one() { assert_eq!(container_cert_name("corporate-root"), "corporate-root.crt"); } #[test] fn only_the_last_extension_is_replaced() { assert_eq!(container_cert_name("corp.root.ca.pem"), "corp.root.ca.crt"); } #[test] fn unsafe_characters_are_replaced() { assert_eq!( container_cert_name("Corp Root CA (2026).pem"), "Corp_Root_CA__2026_.crt" ); assert_eq!(container_cert_name("a/b.pem"), "a_b.crt"); } #[test] fn leading_dots_are_stripped_so_the_file_is_not_hidden() { assert_eq!(container_cert_name(".hidden.pem"), "hidden.crt"); } #[test] fn a_degenerate_name_still_produces_a_usable_file() { assert_eq!(container_cert_name(".pem"), "pem.crt"); assert_eq!(container_cert_name(""), "corporate-ca.crt"); assert_eq!(container_cert_name("..."), "corporate-ca.crt"); } #[test] fn every_produced_name_ends_in_lowercase_crt() { for input in [ "a.pem", "b.CRT", "c", ".d.pem", "", "e f.cer", "...", "ç.pem", ] { let out = container_cert_name(input); assert!( out.ends_with(".crt"), "{:?} produced {:?}, which update-ca-certificates would ignore", input, out ); assert!( out.chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'), "{:?} produced {:?}, which is not shell-safe", input, out ); } } // ── fingerprint ──────────────────────────────────────────────────────── #[test] fn no_configured_path_fingerprints_as_empty() { assert_eq!(compute_ca_fingerprint(None), ""); assert_eq!(compute_ca_fingerprint(Some("")), ""); assert_eq!(compute_ca_fingerprint(Some(" ")), ""); } #[test] fn changing_the_path_changes_the_fingerprint() { let a = TempDir::new("path-a"); let b = TempDir::new("path-b"); // Identical *content* in both, so only the path differs. a.write("corp.pem", "CERT-BODY"); b.write("corp.pem", "CERT-BODY"); let fp_a = compute_ca_fingerprint(Some(a.path().to_str().unwrap())); let fp_b = compute_ca_fingerprint(Some(b.path().to_str().unwrap())); assert_ne!(fp_a, ""); assert_ne!( fp_a, fp_b, "two different paths must not share a fingerprint" ); } #[test] fn changing_the_certificate_content_at_the_same_path_changes_the_fingerprint() { // The case a path-only fingerprint would miss: the corporate CA is // rotated and the new one dropped in at exactly the same location. let dir = TempDir::new("rotate"); dir.write("corp.pem", "OLD-CERT"); let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); dir.write("corp.pem", "NEW-CERT"); let after = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); assert_ne!( before, after, "replacing the certificate at the same path must force a recreation" ); } #[test] fn adding_or_removing_a_certificate_changes_the_fingerprint() { let dir = TempDir::new("add"); dir.write("one.pem", "A"); let one = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); dir.write("two.pem", "B"); let two = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); assert_ne!(one, two); fs::remove_file(dir.path().join("two.pem")).unwrap(); assert_eq!(compute_ca_fingerprint(Some(dir.path().to_str().unwrap())), one); } #[test] fn an_unchanged_directory_fingerprints_identically() { let dir = TempDir::new("stable"); dir.write("corp.pem", "SAME"); let a = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); let b = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); assert_eq!(a, b, "the fingerprint must not churn on repeated reads"); } #[test] fn a_missing_path_fingerprints_differently_from_a_present_one() { let dir = TempDir::new("missing"); let present = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); let missing = compute_ca_fingerprint(Some(&format!("{}-gone", dir.path().to_str().unwrap()))); assert_ne!(present, missing); assert_ne!(missing, ""); } #[test] fn non_certificate_files_in_the_directory_are_ignored() { let dir = TempDir::new("noise"); dir.write("corp.pem", "CERT"); let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); dir.write("README.md", "hello"); dir.write("openssl.cnf", "[req]"); assert_eq!( compute_ca_fingerprint(Some(dir.path().to_str().unwrap())), before ); } // ── resolve ──────────────────────────────────────────────────────────── #[test] fn no_path_resolves_to_nothing() { assert_eq!(resolve(None).unwrap(), None); assert_eq!(resolve(Some(" ")).unwrap(), None); } #[test] fn a_missing_path_is_an_actionable_error() { let err = resolve(Some("/definitely/not/here/corp.pem")).unwrap_err(); assert!(err.contains("/definitely/not/here/corp.pem"), "{}", err); assert!(err.contains("Settings"), "{}", err); } #[test] fn an_empty_directory_is_an_actionable_error() { let dir = TempDir::new("empty"); let err = resolve(Some(dir.path().to_str().unwrap())).unwrap_err(); assert!(err.contains("no certificate files"), "{}", err); assert!(err.contains(".pem"), "{}", err); } #[test] fn a_directory_mounts_at_the_shared_mount_point() { let dir = TempDir::new("dir"); dir.write("corp.pem", "CERT"); let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap().unwrap(); assert!(resolved.is_dir); assert_eq!(resolved.mount_target, CA_MOUNT_DIR); assert_eq!(resolved.cert_files.len(), 1); } #[test] fn a_single_file_mounts_under_the_mount_point_with_a_crt_name() { // Mounting a file *at* /tmp/.host-ca would leave the entrypoint with no // name to work from, and would make the mount point a file rather than // the directory the entrypoint expects. let dir = TempDir::new("file"); let file = dir.write("corp root.pem", "CERT"); let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap(); assert!(!resolved.is_dir); assert_eq!( resolved.mount_target, format!("{}/corp_root.crt", CA_MOUNT_DIR) ); } #[test] fn a_file_is_accepted_whatever_its_extension() { // The user pointed at it explicitly; don't second-guess. let dir = TempDir::new("odd-ext"); let file = dir.write("corp.txt", "CERT"); let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap(); assert_eq!(resolved.cert_files, vec![file]); } // ── env vars ─────────────────────────────────────────────────────────── #[test] fn configured_ca_points_every_consumer_at_the_bundle() { let dir = TempDir::new("env"); dir.write("corp.pem", "CERT"); let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap(); let vars = ca_env_vars(resolved.as_ref()); assert_eq!( vars, vec![ (NODE_EXTRA_CA_CERTS, CA_BUNDLE_PATH.to_string()), (REQUESTS_CA_BUNDLE, CA_BUNDLE_PATH.to_string()), (SSL_CERT_FILE, CA_BUNDLE_PATH.to_string()), ] ); } #[test] fn no_ca_clears_every_var_rather_than_omitting_it() { // Omitting them would let a value baked into the project's snapshot // image survive the setting being turned off. let vars = ca_env_vars(None); assert_eq!(vars.len(), CA_ENV_KEYS.len()); assert!(vars.iter().all(|(_, v)| v.is_empty())); } }