use anyhow::{Context, Result, anyhow, bail}; use base64::Engine; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use std::collections::{BTreeMap, HashMap}; use std::ffi::OsStr; use std::fs; use std::io::{ErrorKind, Write}; #[cfg(unix)] use std::os::unix::fs::{PermissionsExt, symlink}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; const DEFAULT_ROOT: &str = "/"; const DEFAULT_ANCHORS_DIR: &str = "/etc/ca-certificates/trust-source/anchors"; const DEFAULT_EXTRACTED_DIR: &str = "/etc/ca-certificates/extracted"; const DEFAULT_SSL_CERTS_DIR: &str = "/etc/ssl/certs"; const DEFAULT_SSL_CERT_PEM_LINK: &str = "/etc/ssl/cert.pem"; const DEFAULT_SSL_CA_CERTIFICATES_BUNDLE_LINK: &str = "/etc/ssl/certs/ca-certificates.crt"; const DEFAULT_SSL_CA_BUNDLE_CRT_LINK: &str = "/etc/ssl/certs/ca-bundle.crt"; const DEFAULT_JAVA_CACERTS_LINK: &str = "/etc/ssl/certs/java/cacerts"; const DEFAULT_PKI_TLS_CA_BUNDLE_CRT_LINK: &str = "/etc/pki/tls/certs/ca-bundle.crt"; const DEFAULT_PKI_TLS_CA_BUNDLE_TRUST_CRT_LINK: &str = "/etc/pki/tls/certs/ca-bundle.trust.crt"; const DEFAULT_PKI_TLS_JAVA_CACERTS_LINK: &str = "/etc/pki/tls/java/cacerts"; const TARGET_COMMAND_FALLBACK_DIRS: &[&str] = &[ "/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin", ]; const DEFAULT_CERTDATA_URL: &str = "https://hg.mozilla.org/projects/nss/raw-file/tip/lib/ckfw/builtins/certdata.txt"; const DEFAULT_CERTDATA_OUTPUT: &str = "certdata.txt"; const DEFAULT_MOZILLA_TRUST_P11KIT: &str = "/usr/share/ca-certificates/trust-source/mozilla.trust.p11-kit"; const DEFAULT_ETC_MOZILLA_TRUST_P11KIT: &str = "/etc/ca-certificates/trust-source/mozilla.trust.p11-kit"; const DEFAULT_PKI_MOZILLA_TRUST_P11KIT: &str = "/etc/pki/anchors/mozilla.trust.p11-kit"; const DEFAULT_PKI_CA_TRUST_SOURCE_MOZILLA_TRUST_P11KIT: &str = "/etc/pki/ca-trust/source/mozilla.trust.p11-kit"; const MOZILLA_HG_BOOTSTRAP_ROOT_PEM: &str = r#"-----BEGIN CERTIFICATE----- MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= -----END CERTIFICATE----- "#; const MOZILLA_HG_BOOTSTRAP_DIGICERT_G2_ROOT_PEM: &str = r#"-----BEGIN CERTIFICATE----- MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI 2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx 1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV 5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY 1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl MrY= -----END CERTIFICATE----- "#; // Top-level CLI entrypoint with `add` and `extract` subcommands. #[derive(Parser, Debug)] #[command( version, about = "Manage Linux CA trust store sources and extracted bundles (p11-kit/trust)" )] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] enum Commands { /// Add a PEM CA certificate to trust-source/anchors and refresh extracted outputs Add(AddArgs), /// Regenerate extracted trust bundles (like update-ca-trust extract) Extract(ExtractArgs), /// Fetch and parse Mozilla/NSS certdata.txt (no certificate generation yet) Certdata { #[command(subcommand)] command: CertdataCommands, }, #[command(hide = true)] GenArtifacts(GenArtifactsArgs), } // Common flags shared by subcommands. #[derive(Args, Debug)] struct CommonArgs { /// Target root filesystem (for chroot/image builds) #[arg(long, default_value = DEFAULT_ROOT)] root: PathBuf, /// Print actions without modifying files #[arg(long)] dry_run: bool, } // `add` installs a new anchor and optionally runs extraction. #[derive(Args, Debug)] struct AddArgs { /// Path to a PEM-encoded CA certificate cert: PathBuf, /// Output certificate name (without extension). Defaults to the input filename stem. #[arg(short, long)] name: Option, /// Overwrite an existing anchor if contents differ #[arg(long)] force: bool, /// Add certificate but do not run extraction #[arg(long)] no_extract: bool, /// Extracted output directory inside the target root (default: /etc/ca-certificates/extracted) #[arg(short = 'o', long)] output: Option, #[command(flatten)] common: CommonArgs, } // `extract` rebuilds derived trust outputs only. #[derive(Args, Debug)] struct ExtractArgs { /// Extracted output directory inside the target root (default: /etc/ca-certificates/extracted) #[arg(short = 'o', long)] output: Option, #[command(flatten)] common: CommonArgs, } // `certdata` helper subcommands used for the future make-ca style pipeline. #[derive(Subcommand, Debug)] enum CertdataCommands { /// Fetch certdata.txt from an NSS source URL Fetch(CertdataFetchArgs), /// Parse a local certdata.txt file and print a summary Parse(CertdataParseArgs), /// Convert certdata.txt into a Mozilla p11-kit trust bundle and optionally extract outputs Convert(CertdataConvertArgs), /// Run the full pipeline: fetch certdata.txt, convert to p11-kit source, then extract outputs Sync(CertdataSyncArgs), } #[derive(Args, Debug)] struct CertdataFetchArgs { /// Source URL for certdata.txt #[arg(long, default_value = DEFAULT_CERTDATA_URL)] url: String, /// Override log URL used to determine the latest revision (optional) #[arg(long)] log_url: Option, /// Output file path #[arg(short, long, default_value = DEFAULT_CERTDATA_OUTPUT)] output: PathBuf, /// Overwrite an existing file if contents differ #[arg(long)] force: bool, /// Always download even if the local file revision matches the remote revision #[arg(long)] no_revision_check: bool, /// Skip parsing/summary after download #[arg(long = "no-parse", action = clap::ArgAction::SetFalse, default_value_t = true)] parse: bool, /// Print actions without modifying files #[arg(long)] dry_run: bool, } #[derive(Args, Debug)] struct CertdataParseArgs { /// Path to certdata.txt #[arg(default_value = DEFAULT_CERTDATA_OUTPUT)] input: PathBuf, /// Print up to N object summaries after the aggregate stats #[arg(long, default_value_t = 5)] limit: usize, } #[derive(Args, Debug)] struct CertdataConvertArgs { /// Path to certdata.txt #[arg(default_value = DEFAULT_CERTDATA_OUTPUT)] input: PathBuf, /// Target root filesystem (for chroot/image builds) #[arg(long, default_value = DEFAULT_ROOT)] root: PathBuf, /// Destination p11-kit trust source file inside the target root #[arg(long, default_value = DEFAULT_MOZILLA_TRUST_P11KIT)] output: PathBuf, /// Overwrite an existing output file if contents differ #[arg(long)] force: bool, /// Skip running trust extraction after writing the Mozilla source bundle #[arg(long)] no_extract: bool, /// Extracted output directory inside the target root (default: /etc/ca-certificates/extracted) #[arg(short = 'o', long)] extract_output: Option, /// Print actions without modifying files #[arg(long)] dry_run: bool, } #[derive(Args, Debug)] struct CertdataSyncArgs { /// Source URL for certdata.txt #[arg(long, default_value = DEFAULT_CERTDATA_URL)] url: String, /// Override log URL used to determine the latest revision (optional) #[arg(long)] log_url: Option, /// Local certdata.txt path used for fetch and convert. /// Defaults to a temporary `/tmp/.../certdata.txt` path that is cleaned up. #[arg(long)] certdata_output: Option, /// Target root filesystem (for chroot/image builds) #[arg(long, default_value = DEFAULT_ROOT)] root: PathBuf, /// Destination p11-kit trust source file inside the target root #[arg(long, default_value = DEFAULT_MOZILLA_TRUST_P11KIT)] mozilla_output: PathBuf, /// Overwrite existing files if contents differ #[arg(long)] force: bool, /// Always download even if the local certdata revision matches the remote revision #[arg(long)] no_revision_check: bool, /// Skip parsing/summary after download #[arg(long = "no-parse", action = clap::ArgAction::SetFalse, default_value_t = true)] parse: bool, /// Skip running trust extraction after writing the Mozilla source bundle #[arg(long)] no_extract: bool, /// Extracted output directory inside the target root (default: /etc/ca-certificates/extracted) #[arg(short = 'o', long)] extract_output: Option, /// Print actions without modifying files #[arg(long)] dry_run: bool, } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] enum CompletionShell { Bash, Fish, Zsh, } #[derive(Args, Debug)] struct GenArtifactsArgs { /// Directory to write generated packaging artifacts into #[arg(long, default_value = "contrib")] out_dir: PathBuf, /// Shell completions to generate #[arg(long = "shell", value_enum, num_args = 1.., default_values_t = [CompletionShell::Bash, CompletionShell::Fish, CompletionShell::Zsh])] shells: Vec, /// Print actions without modifying files #[arg(long)] dry_run: bool, } // A single `trust extract` output we want to generate. #[derive(Clone, Copy, Debug)] struct ExtractJob { format: &'static str, filter: &'static str, purpose: Option<&'static str>, comment: bool, relative_dest: &'static str, } #[derive(Debug)] struct TempWorkspaceCleanup { path: PathBuf, } impl Drop for TempWorkspaceCleanup { fn drop(&mut self) { if let Err(err) = fs::remove_dir_all(&self.path) { if err.kind() != std::io::ErrorKind::NotFound { eprintln!( "warning: failed to remove temporary directory {}: {err}", self.path.display() ); } } } } // Output set mirrors `/bin/update-ca-trust extract`. const EXTRACT_JOBS: &[ExtractJob] = &[ ExtractJob { format: "pem-bundle", filter: "ca-anchors", purpose: Some("server-auth"), comment: true, relative_dest: "tls-ca-bundle.pem", }, ExtractJob { format: "pem-bundle", filter: "ca-anchors", purpose: Some("email"), comment: true, relative_dest: "email-ca-bundle.pem", }, ExtractJob { format: "pem-bundle", filter: "ca-anchors", purpose: Some("code-signing"), comment: true, relative_dest: "objsign-ca-bundle.pem", }, ExtractJob { format: "openssl-bundle", filter: "certificates", purpose: None, comment: true, relative_dest: "ca-bundle.trust.crt", }, ExtractJob { format: "edk2-cacerts", filter: "ca-anchors", purpose: Some("server-auth"), comment: false, relative_dest: "edk2-cacerts.bin", }, ExtractJob { format: "java-cacerts", filter: "ca-anchors", purpose: Some("server-auth"), comment: false, relative_dest: "java-cacerts.jks", }, ExtractJob { format: "openssl-directory", filter: "ca-anchors", purpose: None, comment: true, relative_dest: "cadir", }, ]; // Parsed representation of NSS certdata.txt. #[derive(Debug, Clone, PartialEq, Eq)] struct CertDataDocument { revision: Option, objects: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] struct CertDataObject { attributes: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] struct CertDataAttribute { name: String, value_type: String, value: CertDataValue, } #[derive(Debug, Clone, PartialEq, Eq)] enum CertDataValue { Inline(String), Multiline(Vec), } impl CertDataObject { fn attr(&self, name: &str) -> Option<&CertDataAttribute> { self.attributes.iter().find(|attr| attr.name == name) } fn inline_attr_value(&self, name: &str) -> Option<&str> { match self.attr(name) { Some(CertDataAttribute { value: CertDataValue::Inline(value), .. }) => Some(value.as_str()), _ => None, } } fn multiline_attr_lines(&self, name: &str) -> Option<&[String]> { match self.attr(name) { Some(CertDataAttribute { value: CertDataValue::Multiline(lines), .. }) => Some(lines.as_slice()), _ => None, } } } fn main() { if let Err(err) = run() { eprintln!("error: {err:#}"); std::process::exit(1); } } fn run() -> Result<()> { // This tool manipulates Linux trust-store paths and relies on `trust`. if !cfg!(target_os = "linux") { bail!("this tool targets Linux trust stores"); } let cli = Cli::parse(); match cli.command { Commands::Add(args) => run_add(args), Commands::Extract(args) => run_extract(args), Commands::Certdata { command } => run_certdata(command), Commands::GenArtifacts(args) => run_gen_artifacts(args), } } fn run_certdata(command: CertdataCommands) -> Result<()> { match command { CertdataCommands::Fetch(args) => run_certdata_fetch(args), CertdataCommands::Parse(args) => run_certdata_parse(args), CertdataCommands::Convert(args) => run_certdata_convert(args), CertdataCommands::Sync(args) => run_certdata_sync(args), } } fn run_certdata_sync(args: CertdataSyncArgs) -> Result<()> { let (certdata_output, _temp_workspace_guard) = resolve_sync_certdata_output(args.certdata_output, args.dry_run)?; // Reuse the existing subcommand implementations so behavior stays aligned. run_certdata_fetch(CertdataFetchArgs { url: args.url, log_url: args.log_url, output: certdata_output.clone(), force: args.force, no_revision_check: args.no_revision_check, parse: args.parse, dry_run: args.dry_run, })?; run_certdata_convert(CertdataConvertArgs { input: certdata_output, root: args.root, output: args.mozilla_output, force: args.force, no_extract: args.no_extract, extract_output: args.extract_output, dry_run: args.dry_run, }) } fn resolve_sync_certdata_output( requested: Option, dry_run: bool, ) -> Result<(PathBuf, Option)> { if let Some(path) = requested { return Ok((path, None)); } if dry_run { return Ok(( build_sync_tmp_workspace_path(0).join(DEFAULT_CERTDATA_OUTPUT), None, )); } let workspace = create_sync_tmp_workspace_dir()?; println!( "Using temporary certdata workspace: {}", workspace.display() ); Ok(( workspace.join(DEFAULT_CERTDATA_OUTPUT), Some(TempWorkspaceCleanup { path: workspace }), )) } fn create_sync_tmp_workspace_dir() -> Result { let tmp_root = Path::new("/tmp"); for attempt in 0..32_u32 { let candidate = build_sync_tmp_workspace_path(attempt); match fs::create_dir(&candidate) { Ok(()) => return Ok(candidate), Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(err) => { return Err(err).with_context(|| { format!( "failed to create temporary certdata directory {}", candidate.display() ) }); } } } bail!( "failed to create a unique temporary certdata directory under {}", tmp_root.display() ); } fn build_sync_tmp_workspace_path(attempt: u32) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); Path::new("/tmp").join(format!( "ca-certs-certdata-{}-{nonce:032x}-{attempt:02x}", std::process::id() )) } fn run_gen_artifacts(args: GenArtifactsArgs) -> Result<()> { let man_path = args.out_dir.join("ca-certs.8"); let completions_dir = args.out_dir.join("completions"); println!( "Generating packaging artifacts in {}", args.out_dir.display() ); println!("Man page: {}", man_path.display()); println!("Completions dir: {}", completions_dir.display()); if args.dry_run { println!("[dry-run] Would create {}", args.out_dir.display()); println!("[dry-run] Would create {}", completions_dir.display()); for shell in &args.shells { println!( "[dry-run] Would generate {} completion", completion_shell_name(*shell) ); } println!("[dry-run] Would generate man page ca-certs.8"); return Ok(()); } fs::create_dir_all(&args.out_dir) .with_context(|| format!("failed to create {}", args.out_dir.display()))?; fs::create_dir_all(&completions_dir) .with_context(|| format!("failed to create {}", completions_dir.display()))?; let mut man_buf = Vec::new(); clap_mangen::Man::new(Cli::command()) .section("8") .render(&mut man_buf) .context("failed to render man page")?; fs::write(&man_path, &man_buf) .with_context(|| format!("failed to write {}", man_path.display()))?; for shell in args.shells { let clap_shell = match shell { CompletionShell::Bash => clap_complete::Shell::Bash, CompletionShell::Fish => clap_complete::Shell::Fish, CompletionShell::Zsh => clap_complete::Shell::Zsh, }; let mut cmd = Cli::command(); let generated = clap_complete::generate_to(clap_shell, &mut cmd, "ca-certs", &completions_dir) .with_context(|| { format!( "failed to generate {} completion", completion_shell_name(shell) ) })?; println!("Generated completion: {}", generated.display()); } println!("Generated man page: {}", man_path.display()); Ok(()) } fn completion_shell_name(shell: CompletionShell) -> &'static str { match shell { CompletionShell::Bash => "bash", CompletionShell::Fish => "fish", CompletionShell::Zsh => "zsh", } } fn run_add(args: AddArgs) -> Result<()> { // Validate the input before touching the trust store. let cert_bytes = fs::read(&args.cert) .with_context(|| format!("failed to read certificate file {}", args.cert.display()))?; ensure_pem_certificate(&cert_bytes)?; let cert_name = sanitize_name(args.name.as_deref().unwrap_or_else(|| { args.cert .file_stem() .and_then(OsStr::to_str) .unwrap_or("custom-ca") })); if cert_name.is_empty() { bail!("certificate name resolved to an empty value"); } // Anchors are source inputs; extracted bundles are generated from them. let anchors_dir_target = PathBuf::from(DEFAULT_ANCHORS_DIR); let anchors_dir_host = path_in_root(&args.common.root, &anchors_dir_target); let anchor_file = anchors_dir_host.join(format!("{cert_name}.pem")); println!("Anchor dir: {}", anchors_dir_host.display()); println!("Anchor file: {}", anchor_file.display()); install_file(&anchor_file, &cert_bytes, args.force, args.common.dry_run)?; if args.no_extract { println!("Skipping extraction (`--no-extract`)."); return Ok(()); } let output_target = args .output .unwrap_or_else(|| PathBuf::from(DEFAULT_EXTRACTED_DIR)); extract_trust(&args.common.root, &output_target, args.common.dry_run)?; println!("CA certificate added and trust store refreshed."); Ok(()) } fn run_extract(args: ExtractArgs) -> Result<()> { let output_target = args .output .unwrap_or_else(|| PathBuf::from(DEFAULT_EXTRACTED_DIR)); extract_trust(&args.common.root, &output_target, args.common.dry_run)?; println!("Trust store extraction complete."); Ok(()) } fn run_certdata_fetch(args: CertdataFetchArgs) -> Result<()> { println!("Fetching certdata.txt from {}", args.url); println!("Output: {}", args.output.display()); let log_url = args .log_url .clone() .or_else(|| derive_hg_log_url(&args.url)) .filter(|s| !s.is_empty()); if let Some(log_url) = &log_url { println!("Revision check URL: {log_url}"); } else if !args.no_revision_check { println!( "Revision check URL: ; downloading unconditionally" ); } if args.dry_run { println!( "[dry-run] Would fetch {} -> {}", args.url, args.output.display() ); if let Some(log_url) = &log_url { println!("[dry-run] Would check remote revision via {log_url}"); } return Ok(()); } let client = build_http_client().context("failed to build HTTP client")?; let existing_revision = read_certdata_revision_from_path(&args.output) .ok() .flatten(); let mut remote_revision = None; if !args.no_revision_check { if let Some(log_url) = &log_url { let log_html = http_get_text(&client, log_url)?; remote_revision = extract_hg_revision_from_log_html(&log_html).ok(); if let Some(rev) = &remote_revision { println!("Remote revision: {rev}"); if !args.force && existing_revision.as_deref() == Some(rev.as_str()) { println!( "No update required (local revision matches remote). Use --force to download anyway." ); return Ok(()); } } else { println!("Could not parse revision from log page; continuing with download."); } } } let raw_body = http_get_text(&client, &args.url)?; let normalized_body = normalize_certdata_download(&raw_body, remote_revision.clone()); let parsed = parse_certdata(&normalized_body).context("downloaded certdata.txt failed to parse")?; if let (Some(old), Some(new)) = (existing_revision.as_deref(), parsed.revision.as_deref()) { if old == new && !args.force { println!( "No update required (downloaded revision matches local file). Use --force to overwrite anyway." ); return Ok(()); } } if args.parse { print_certdata_summary(&parsed, 5); } install_file(&args.output, normalized_body.as_bytes(), args.force, false)?; println!("certdata.txt fetched successfully."); Ok(()) } fn http_get_text(client: &reqwest::blocking::Client, url: &str) -> Result { let response = match client.get(url).send() { Ok(response) => response, Err(_err) if is_mozilla_hg_url(url) => { println!("reqwest fetch failed for Mozilla hg URL; retrying via openssl."); return openssl_https_get_text(url); } Err(err) => return Err(err).with_context(|| format!("failed to request {url}")), } .error_for_status() .with_context(|| format!("server returned an error for {url}"))?; response.text().context("failed to read response body") } fn is_mozilla_hg_url(url: &str) -> bool { reqwest::Url::parse(url) .ok() .and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase)) .map(|host| host == "hg.mozilla.org" || host == "hg-edge.mozilla.org") .unwrap_or(false) } fn openssl_https_get_text(url: &str) -> Result { let parsed = reqwest::Url::parse(url).with_context(|| format!("invalid URL: {url}"))?; if parsed.scheme() != "https" { bail!("openssl fallback only supports https URLs: {url}"); } let host = parsed.host_str().context("URL missing host")?; let port = parsed.port_or_known_default().context("URL missing port")?; let mut path = parsed.path().to_string(); if path.is_empty() { path.push('/'); } if let Some(query) = parsed.query() { path.push('?'); path.push_str(query); } let host_header = match parsed.port() { Some(p) if p != 443 => format!("{host}:{p}"), _ => host.to_string(), }; let bootstrap_ca_path = write_bootstrap_ca_tempfile()?; let result = (|| { let mut cmd = Command::new("openssl"); cmd.args(openssl_s_client_args(&bootstrap_ca_path, host, port)); if Path::new(DEFAULT_SSL_CERTS_DIR).is_dir() { cmd.args(openssl_s_client_capath_args(Path::new( DEFAULT_SSL_CERTS_DIR, ))); } cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd.spawn().context("failed to spawn `openssl s_client`")?; { let stdin = child .stdin .as_mut() .context("failed to open openssl stdin")?; write!( stdin, "GET {path} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n" ) .context("failed to write HTTP request to openssl stdin")?; } let output = child .wait_with_output() .context("failed to wait for `openssl s_client`")?; if !output.status.success() { bail!( "openssl s_client failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } parse_http_response_text(&output.stdout) .with_context(|| format!("failed to parse HTTP response from openssl for {url}")) })(); let _ = fs::remove_file(&bootstrap_ca_path); result } fn openssl_s_client_args(bootstrap_ca_path: &Path, host: &str, port: u16) -> Vec { vec![ "s_client".to_string(), "-quiet".to_string(), "-verify_return_error".to_string(), "-CAfile".to_string(), bootstrap_ca_path.display().to_string(), "-connect".to_string(), format!("{host}:{port}"), "-servername".to_string(), host.to_string(), ] } fn openssl_s_client_capath_args(ca_path: &Path) -> Vec { vec!["-CApath".to_string(), ca_path.display().to_string()] } fn write_bootstrap_ca_tempfile() -> Result { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let path = std::env::temp_dir().join(format!( "ca-certs-hg-bootstrap-{}-{nonce}.pem", std::process::id() )); let bundle = format!( "{}{}", MOZILLA_HG_BOOTSTRAP_ROOT_PEM, MOZILLA_HG_BOOTSTRAP_DIGICERT_G2_ROOT_PEM ); fs::write(&path, bundle).with_context(|| format!("failed to write {}", path.display()))?; #[cfg(unix)] { fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) .with_context(|| format!("failed to set permissions on {}", path.display()))?; } Ok(path) } fn parse_http_response_text(raw: &[u8]) -> Result { let (header_bytes, body_bytes) = split_http_response(raw)?; let header_text = String::from_utf8_lossy(header_bytes); let mut header_lines = header_text.lines(); let status_line = header_lines.next().context("missing HTTP status line")?; let status_code = status_line .split_whitespace() .nth(1) .context("missing HTTP status code")? .parse::() .context("invalid HTTP status code")?; if !(200..300).contains(&status_code) { bail!("HTTP request failed with status {status_code}"); } let chunked = header_lines.any(|line| { line.split_once(':') .map(|(name, value)| { name.eq_ignore_ascii_case("transfer-encoding") && value.to_ascii_lowercase().contains("chunked") }) .unwrap_or(false) }); let body = if chunked { decode_http_chunked_body(body_bytes)? } else { body_bytes.to_vec() }; String::from_utf8(body).context("HTTP response body was not UTF-8") } fn split_http_response(raw: &[u8]) -> Result<(&[u8], &[u8])> { if let Some(idx) = raw.windows(4).position(|w| w == b"\r\n\r\n") { return Ok((&raw[..idx], &raw[idx + 4..])); } if let Some(idx) = raw.windows(2).position(|w| w == b"\n\n") { return Ok((&raw[..idx], &raw[idx + 2..])); } bail!("HTTP response missing header/body separator") } fn decode_http_chunked_body(mut input: &[u8]) -> Result> { let mut out = Vec::new(); loop { let (line, rest) = take_http_line(input).context("truncated chunk header")?; let size_hex = line.split(';').next().unwrap_or("").trim(); let size = usize::from_str_radix(size_hex, 16) .with_context(|| format!("invalid chunk size `{size_hex}`"))?; input = rest; if size == 0 { break; } if input.len() < size { bail!("truncated chunk body"); } out.extend_from_slice(&input[..size]); input = &input[size..]; if input.starts_with(b"\r\n") { input = &input[2..]; } else if input.starts_with(b"\n") { input = &input[1..]; } else { bail!("missing chunk terminator"); } } Ok(out) } fn take_http_line(input: &[u8]) -> Option<(String, &[u8])> { if let Some(idx) = input.windows(2).position(|w| w == b"\r\n") { let line = String::from_utf8(input[..idx].to_vec()).ok()?; return Some((line, &input[idx + 2..])); } if let Some(idx) = input.iter().position(|b| *b == b'\n') { let line = String::from_utf8(input[..idx].to_vec()).ok()?; return Some((line, &input[idx + 1..])); } None } fn build_http_client() -> Result { let bootstrap_ca = reqwest::Certificate::from_pem(MOZILLA_HG_BOOTSTRAP_ROOT_PEM.as_bytes()) .context("failed to parse bundled Mozilla hg bootstrap root certificate")?; let digicert_g2_ca = reqwest::Certificate::from_pem(MOZILLA_HG_BOOTSTRAP_DIGICERT_G2_ROOT_PEM.as_bytes()) .context("failed to parse bundled DigiCert Global Root G2 bootstrap certificate")?; reqwest::blocking::Client::builder() .add_root_certificate(bootstrap_ca) .add_root_certificate(digicert_g2_ca) .build() .context("failed to construct reqwest client") } fn derive_hg_log_url(raw_url: &str) -> Option { if raw_url.contains("/raw-file/") { Some(raw_url.replacen("/raw-file/", "/log/", 1)) } else { None } } fn extract_hg_revision_from_log_html(html: &str) -> Result { // make-ca grabs the first `` line and takes the text before `<`, which is the changeset id. for line in html.lines() { let line = line.trim(); if !line.contains("") { continue; } let prefix = line.split('<').next().unwrap_or("").trim(); if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_hexdigit()) { return Ok(prefix.to_string()); } } bail!("unable to find a changeset revision in hg log HTML") } fn read_certdata_revision_from_path(path: &Path) -> Result> { if !path.exists() { return Ok(None); } let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; Ok(extract_certdata_revision_from_text(&content)) } fn extract_certdata_revision_from_text(content: &str) -> Option { content.lines().find_map(|line| { let trimmed = line.trim_start(); let rest = trimmed.strip_prefix('#')?.trim_start(); let rev = rest.strip_prefix("Revision:")?.trim(); if rev.is_empty() { None } else { Some(rev.to_string()) } }) } fn normalize_certdata_download(raw_body: &str, revision: Option) -> String { // Ensure a single make-ca style `# Revision:` line exists near the top. let normalized_lines: Vec<&str> = raw_body .lines() .filter(|line| { let trimmed = line.trim_start(); let Some(rest) = trimmed.strip_prefix('#') else { return true; }; let rest = rest.trim_start(); !rest.starts_with("Revision:") }) .collect(); let mut out = String::new(); if let Some(rev) = revision { out.push_str("# Revision:"); out.push_str(&rev); out.push('\n'); } out.push_str(&normalized_lines.join("\n")); if raw_body.ends_with('\n') && !out.ends_with('\n') { out.push('\n'); } out } fn run_certdata_parse(args: CertdataParseArgs) -> Result<()> { let content = fs::read_to_string(&args.input) .with_context(|| format!("failed to read {}", args.input.display()))?; let parsed = parse_certdata(&content) .with_context(|| format!("failed to parse {}", args.input.display()))?; print_certdata_summary(&parsed, args.limit); Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TrustUsage { Trusted, Distrusted, Neutral, } #[derive(Debug, Clone)] struct MozillaCertEntry { join_key: String, label: String, der_cert: Vec, mozilla_policy: bool, server_distrust_after: Option, email_distrust_after: Option, } #[derive(Debug, Clone, Copy)] struct MozillaTrustEntry { server_auth: TrustUsage, email: TrustUsage, code_signing: TrustUsage, } fn run_certdata_convert(args: CertdataConvertArgs) -> Result<()> { let output_target = args.output; let output_host = path_in_root(&args.root, &output_target); println!("Converting certdata: {}", args.input.display()); println!("Output p11-kit (target path): {}", output_target.display()); println!("Output p11-kit (host path): {}", output_host.display()); // Convert parsed NSS objects into the same p11-kit source style Arch ships. let parsed = if args.dry_run && !args.input.exists() { println!( "[dry-run] Input {} does not exist yet; skipping parse counts", args.input.display() ); None } else { let content = fs::read_to_string(&args.input) .with_context(|| format!("failed to read {}", args.input.display()))?; Some( parse_certdata(&content) .with_context(|| format!("failed to parse {}", args.input.display()))?, ) }; let mut rendered_p11kit: Option = None; if args.dry_run { if let Some(parsed) = &parsed { let (certs, trusts) = count_cert_and_trust_objects(parsed); println!("[dry-run] Parsed objects: {}", parsed.objects.len()); println!( "[dry-run] Would convert {certs} certificate objects with {trusts} trust objects" ); } println!("[dry-run] Would write {}", output_host.display()); let fallback_targets = detect_trust_source_fallback_targets(&args.root, &output_target); if !fallback_targets.is_empty() { println!( "[dry-run] If extraction is empty, would retry after installing compatibility trust source(s):" ); for target in fallback_targets { println!(" - {}", path_in_root(&args.root, &target).display()); } } } else { let parsed = parsed.context("certdata parse result missing")?; let p11kit = convert_certdata_to_p11kit_bundle(&parsed)?; install_file(&output_host, p11kit.as_bytes(), args.force, false)?; rendered_p11kit = Some(p11kit); } if args.no_extract { println!("Skipping extraction (`--no-extract`)."); return Ok(()); } let extract_output = args .extract_output .unwrap_or_else(|| PathBuf::from(DEFAULT_EXTRACTED_DIR)); extract_trust(&args.root, &extract_output, args.dry_run)?; if !args.dry_run { if let Err(err) = ensure_nonempty_extraction_outputs(&args.root, &extract_output) { let Some(p11kit) = rendered_p11kit.as_deref() else { return Err(err); }; let fallback_installs = install_fallback_trust_sources(&args.root, &output_target, p11kit, args.force)?; if fallback_installs == 0 { return Err(err); } println!("Retrying trust extraction after installing compatibility trust source(s)."); extract_trust(&args.root, &extract_output, false)?; ensure_nonempty_extraction_outputs(&args.root, &extract_output)?; } } println!("certdata conversion and extraction complete."); Ok(()) } fn detect_trust_source_fallback_targets(_root: &Path, output_target: &Path) -> Vec { if output_target != Path::new(DEFAULT_MOZILLA_TRUST_P11KIT) { return Vec::new(); } let mut targets = Vec::new(); let fallback_candidates = [ DEFAULT_ETC_MOZILLA_TRUST_P11KIT, DEFAULT_PKI_MOZILLA_TRUST_P11KIT, DEFAULT_PKI_CA_TRUST_SOURCE_MOZILLA_TRUST_P11KIT, ]; let output_target = output_target.to_path_buf(); for candidate in fallback_candidates { let candidate_path = PathBuf::from(candidate); if candidate_path == output_target || targets.contains(&candidate_path) { continue; } // Always include fallback paths; install_file() will create parent dirs. // Some chroots do not have /etc/pki* until later packages are installed. targets.push(candidate_path); } targets } fn install_fallback_trust_sources( root: &Path, output_target: &Path, p11kit: &str, force: bool, ) -> Result { let mut installed = 0usize; let targets = detect_trust_source_fallback_targets(root, output_target); if targets.is_empty() { return Ok(0); } for target in targets { let mirror_host = path_in_root(root, &target); println!( "Installing compatibility trust source: {}", mirror_host.display() ); match install_file(&mirror_host, p11kit.as_bytes(), force, false) { Ok(()) => installed += 1, Err(err) => eprintln!( "warning: unable to install compatibility trust source {}: {err}", mirror_host.display() ), } } Ok(installed) } fn ensure_nonempty_extraction_outputs(root: &Path, extract_output: &Path) -> Result<()> { let tls_bundle = path_in_root(root, &extract_output.join("tls-ca-bundle.pem")); let trust_bundle = path_in_root(root, &extract_output.join("ca-bundle.trust.crt")); let cadir = path_in_root(root, &extract_output.join("cadir")); let tls_size = fs::metadata(&tls_bundle).map(|m| m.len()).unwrap_or(0); let trust_size = fs::metadata(&trust_bundle).map(|m| m.len()).unwrap_or(0); let cadir_count = fs::read_dir(&cadir).map(|it| it.count()).unwrap_or(0); if tls_size == 0 || trust_size == 0 || cadir_count == 0 { bail!( "trust extract produced empty outputs (tls-ca-bundle.pem={} bytes, ca-bundle.trust.crt={} bytes, cadir entries={}); p11-kit may be reading a different trust source layout (e.g. BLFS make-ca uses /etc/pki/anchors)", tls_size, trust_size, cadir_count ); } Ok(()) } fn count_cert_and_trust_objects(doc: &CertDataDocument) -> (usize, usize) { let mut certs = 0usize; let mut trusts = 0usize; for obj in &doc.objects { match obj.inline_attr_value("CKA_CLASS") { Some("CKO_CERTIFICATE") => certs += 1, Some("CKO_NSS_TRUST") => trusts += 1, _ => {} } } (certs, trusts) } fn convert_certdata_to_p11kit_bundle(doc: &CertDataDocument) -> Result { // Build separate maps for certificate payloads and trust metadata, then join by issuer+serial. let mut certs = Vec::new(); let mut trusts: HashMap = HashMap::new(); for obj in &doc.objects { match obj.inline_attr_value("CKA_CLASS") { Some("CKO_CERTIFICATE") => { if let Some(entry) = parse_mozilla_cert_object(obj)? { certs.push(entry); } } Some("CKO_NSS_TRUST") => { if let Some((key, trust)) = parse_mozilla_trust_object(obj)? { trusts.insert(key, trust); } } _ => {} } } let mut out = String::new(); out.push_str("# Generated by ca-certs from Mozilla/NSS certdata.txt\n"); if let Some(rev) = &doc.revision { out.push_str("# Revision:"); out.push_str(rev); out.push('\n'); } out.push('\n'); let mut converted = 0usize; let mut skipped_missing_trust = 0usize; for cert in certs { let Some(trust) = trusts.get(&cert.join_key).copied() else { skipped_missing_trust += 1; continue; }; let cert_pem = pem_encode("CERTIFICATE", &cert.der_cert); let pubkey_pem = openssl_pubkey_pem_from_der(&cert.der_cert) .with_context(|| format!("failed to extract public key for {}", cert.label))?; let rendered = render_p11kit_cert_pair(&cert, &trust, &pubkey_pem, &cert_pem)?; out.push_str(&rendered); out.push('\n'); converted += 1; } println!("Converted Mozilla cert/trust pairs: {converted}"); if skipped_missing_trust > 0 { println!("Skipped certificates without matching trust object: {skipped_missing_trust}"); } Ok(out) } fn parse_mozilla_cert_object(obj: &CertDataObject) -> Result> { let join_key = match cert_object_join_key(obj)? { Some(key) => key, None => return Ok(None), }; let der_cert = match obj.multiline_attr_lines("CKA_VALUE") { Some(lines) => decode_certdata_octal_multiline(lines) .context("failed to decode CKA_VALUE certificate DER")?, None => return Ok(None), }; let label = obj .inline_attr_value("CKA_LABEL") .map(certdata_inline_unquote) .unwrap_or_else(|| "".to_string()); Ok(Some(MozillaCertEntry { join_key, label, der_cert, mozilla_policy: parse_ck_bool(obj.inline_attr_value("CKA_NSS_MOZILLA_CA_POLICY")) .unwrap_or(false), server_distrust_after: parse_distrust_after_value( obj.attr("CKA_NSS_SERVER_DISTRUST_AFTER"), )?, email_distrust_after: parse_distrust_after_value(obj.attr("CKA_NSS_EMAIL_DISTRUST_AFTER"))?, })) } fn parse_mozilla_trust_object(obj: &CertDataObject) -> Result> { let join_key = match cert_object_join_key(obj)? { Some(key) => key, None => return Ok(None), }; Ok(Some(( join_key, MozillaTrustEntry { server_auth: parse_trust_usage(obj.inline_attr_value("CKA_TRUST_SERVER_AUTH")), email: parse_trust_usage(obj.inline_attr_value("CKA_TRUST_EMAIL_PROTECTION")), code_signing: parse_trust_usage(obj.inline_attr_value("CKA_TRUST_CODE_SIGNING")), }, ))) } fn cert_object_join_key(obj: &CertDataObject) -> Result> { // issuer+serial matches how NSS trust objects reference certificate objects. let issuer = match obj.multiline_attr_lines("CKA_ISSUER") { Some(v) => decode_certdata_octal_multiline(v).context("failed to decode CKA_ISSUER")?, None => return Ok(None), }; let serial = match obj.multiline_attr_lines("CKA_SERIAL_NUMBER") { Some(v) => { decode_certdata_octal_multiline(v).context("failed to decode CKA_SERIAL_NUMBER")? } None => return Ok(None), }; Ok(Some(format!( "{}:{}", hex_encode_lower(&issuer), hex_encode_lower(&serial) ))) } fn parse_ck_bool(value: Option<&str>) -> Option { match value?.trim() { "CK_TRUE" => Some(true), "CK_FALSE" => Some(false), _ => None, } } fn parse_trust_usage(value: Option<&str>) -> TrustUsage { match value.map(str::trim) { Some("CKT_NSS_TRUSTED_DELEGATOR") => TrustUsage::Trusted, Some("CKT_NSS_NOT_TRUSTED") => TrustUsage::Distrusted, _ => TrustUsage::Neutral, } } fn parse_distrust_after_value(attr: Option<&CertDataAttribute>) -> Result> { let Some(attr) = attr else { return Ok(None) }; match (&*attr.value_type, &attr.value) { ("CK_BBOOL", CertDataValue::Inline(v)) => match v.trim() { "CK_FALSE" => Ok(Some("%00".to_string())), _ => Ok(None), }, (t, CertDataValue::Multiline(lines)) if t.starts_with("MULTILINE_") => { let bytes = decode_certdata_octal_multiline(lines)?; Ok(Some(percent_encode_bytes(&bytes))) } _ => Ok(None), } } fn decode_certdata_octal_multiline(lines: &[String]) -> Result> { let mut out = Vec::new(); for line in lines { decode_certdata_octal_line_into(line, &mut out)?; } Ok(out) } fn decode_certdata_octal_line_into(line: &str, out: &mut Vec) -> Result<()> { // certdata multiline values encode raw bytes as `\ooo` octal escapes. let bytes = line.as_bytes(); let mut i = 0usize; while i < bytes.len() { if bytes[i] == b'\\' { if i + 3 >= bytes.len() { bail!("truncated octal escape in certdata line"); } let oct = &line[i + 1..i + 4]; let value = u8::from_str_radix(oct, 8) .with_context(|| format!("invalid octal escape \\{oct}"))?; out.push(value); i += 4; } else { out.push(bytes[i]); i += 1; } } Ok(()) } fn hex_encode_lower(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { out.push(nibble_to_hex(b >> 4)); out.push(nibble_to_hex(b & 0x0f)); } out } fn nibble_to_hex(n: u8) -> char { match n { 0..=9 => (b'0' + n) as char, 10..=15 => (b'a' + (n - 10)) as char, _ => '?', } } fn percent_encode_bytes(bytes: &[u8]) -> String { let mut out = String::new(); for b in bytes { out.push('%'); out.push(nibble_to_hex(b >> 4)); out.push(nibble_to_hex(b & 0x0f)); } out } fn pem_encode(label: &str, der: &[u8]) -> String { let b64 = base64::engine::general_purpose::STANDARD.encode(der); let mut out = String::new(); out.push_str("-----BEGIN "); out.push_str(label); out.push_str("-----\n"); for chunk in b64.as_bytes().chunks(64) { out.push_str(std::str::from_utf8(chunk).unwrap_or("")); out.push('\n'); } out.push_str("-----END "); out.push_str(label); out.push_str("-----\n"); out } fn openssl_pubkey_pem_from_der(der_cert: &[u8]) -> Result { // Reuse OpenSSL (like make-ca) to avoid implementing X.509 SPKI extraction here. let mut child = Command::new("openssl") .args(["x509", "-inform", "DER", "-pubkey", "-noout"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to spawn `openssl x509`")?; { let stdin = child .stdin .as_mut() .context("failed to open openssl stdin")?; stdin .write_all(der_cert) .context("failed to write DER certificate to openssl stdin")?; } let output = child .wait_with_output() .context("failed to wait for openssl x509")?; if !output.status.success() { bail!( "openssl x509 failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } Ok(String::from_utf8(output.stdout).context("openssl pubkey output was not UTF-8")?) } fn render_p11kit_cert_pair( cert: &MozillaCertEntry, trust: &MozillaTrustEntry, pubkey_pem: &str, cert_pem: &str, ) -> Result { // Each CA emits an extension object (EKU trust policy) and a certificate trust object. let (p11trust_line, p11_oid, p11_value) = p11_trust_tuple(trust)?; let mut out = String::new(); out.push_str("[p11-kit-object-v1]\n"); out.push_str(&format!("label: \"{}\"\n", p11kit_quote(&cert.label))); out.push_str("class: x-certificate-extension\n"); out.push_str(&format!("object-id: {p11_oid}\n")); out.push_str(&format!("value: \"{p11_value}\"\n")); out.push_str("modifiable: false\n"); out.push_str(pubkey_pem); if !pubkey_pem.ends_with('\n') { out.push('\n'); } out.push('\n'); out.push_str("[p11-kit-object-v1]\n"); out.push_str(&format!("label: \"{}\"\n", p11kit_quote(&cert.label))); out.push_str(p11trust_line); out.push('\n'); out.push_str(&format!( "nss-mozilla-ca-policy: {}\n", if cert.mozilla_policy { "true" } else { "false" } )); out.push_str("modifiable: false\n"); if let Some(v) = &cert.server_distrust_after { out.push_str(&format!("nss-server-distrust-after: \"{v}\"\n")); } if let Some(v) = &cert.email_distrust_after { out.push_str(&format!("nss-email-distrust-after: \"{v}\"\n")); } out.push_str(cert_pem); Ok(out) } fn p11kit_quote(input: &str) -> String { input.replace('\\', "\\\\").replace('"', "\\\"") } fn p11_trust_tuple( trust: &MozillaTrustEntry, ) -> Result<(&'static str, &'static str, &'static str)> { // These values mirror make-ca's p11-kit extension constants. let any_distrusted = matches!(trust.server_auth, TrustUsage::Distrusted) || matches!(trust.email, TrustUsage::Distrusted) || matches!(trust.code_signing, TrustUsage::Distrusted); if any_distrusted { return Ok(( "x-distrusted: true", "1.3.6.1.4.1.3319.6.10.1", "0.%06%0a%2b%06%01%04%01%99w%06%0a%01%04 0%1e%06%08%2b%06%01%05%05%07%03%04%06%08%2b%06%01%05%05%07%03%01%06%08%2b%06%01%05%05%07%03%03", )); } let mut key = String::from("p11"); if matches!(trust.server_auth, TrustUsage::Trusted) { key.push_str("sa"); } if matches!(trust.email, TrustUsage::Trusted) { key.push_str("sm"); } if matches!(trust.code_signing, TrustUsage::Trusted) { key.push_str("cs"); } let value = match key.as_str() { "p11sasmcs" => { "0%2a%06%03U%1d%25%01%01%ff%04 0%1e%06%08%2b%06%01%05%05%07%03%04%06%08%2b%06%01%05%05%07%03%01%06%08%2b%06%01%05%05%07%03%03" } "p11sasm" => { "0 %06%03U%1d%25%01%01%ff%04%160%14%06%08%2b%06%01%05%05%07%03%04%06%08%2b%06%01%05%05%07%03%01" } "p11sacs" => { "0 %06%03U%1d%25%01%01%ff%04%160%14%06%08%2b%06%01%05%05%07%03%01%06%08%2b%06%01%05%05%07%03%03" } "p11sa" => "0%16%06%03U%1d%25%01%01%ff%04%0c0%0a%06%08%2b%06%01%05%05%07%03%01", "p11smcs" => { "0 %06%03U%1d%25%01%01%ff%04%160%14%06%08%2b%06%01%05%05%07%03%04%06%08%2b%06%01%05%05%07%03%03" } "p11sm" => "0%16%06%03U%1d%25%01%01%ff%04%0c0%0a%06%08%2b%06%01%05%05%07%03%04", "p11cs" => "0%16%06%03U%1d%25%01%01%ff%04%0c0%0a%06%08%2b%06%01%05%05%07%03%03", "p11" => "0%18%06%03U%1d%25%01%01%ff%04%0e0%0c%06%0a%2b%06%01%04%01%99w%06%0a%10", _ => bail!("unsupported trust combination key {key}"), }; Ok(("trusted: true", "2.5.29.37", value)) } fn parse_certdata(input: &str) -> Result { let mut revision = None; let mut objects = Vec::new(); let mut current_attrs = Vec::new(); let mut pending_multiline: Option<(String, String, Vec)> = None; for (idx, raw_line) in input.lines().enumerate() { let line_no = idx + 1; let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim(); if let Some((name, value_type, lines)) = &mut pending_multiline { if trimmed == "END" { current_attrs.push(CertDataAttribute { name: name.clone(), value_type: value_type.clone(), value: CertDataValue::Multiline(std::mem::take(lines)), }); pending_multiline = None; } else { lines.push(line.to_string()); } continue; } if trimmed.is_empty() { push_certdata_object(&mut objects, &mut current_attrs); continue; } if let Some(rest) = trimmed.strip_prefix('#') { if let Some(rev) = rest.trim().strip_prefix("Revision:") { revision = Some(rev.trim().to_string()); } continue; } // certdata.txt includes top-level markers like BEGINDATA which are not attributes. if trimmed == "BEGINDATA" || trimmed == "ENDDATA" { continue; } let (name, value_type, value) = parse_certdata_attr_header(line) .with_context(|| format!("invalid certdata attribute at line {line_no}: {line}"))?; if value_type.starts_with("MULTILINE_") { if !value.is_empty() { bail!("unexpected trailing data after multiline marker at line {line_no}"); } pending_multiline = Some((name.to_string(), value_type.to_string(), Vec::new())); } else { current_attrs.push(CertDataAttribute { name: name.to_string(), value_type: value_type.to_string(), value: CertDataValue::Inline(value.to_string()), }); } } if let Some((name, _, _)) = pending_multiline { bail!("unterminated multiline value for attribute {name}"); } push_certdata_object(&mut objects, &mut current_attrs); Ok(CertDataDocument { revision, objects }) } fn push_certdata_object( objects: &mut Vec, current_attrs: &mut Vec, ) { if current_attrs.is_empty() { return; } objects.push(CertDataObject { attributes: std::mem::take(current_attrs), }); } fn parse_certdata_attr_header(line: &str) -> Result<(&str, &str, &str)> { let (name, rest) = take_token(line).context("missing attribute name")?; let (value_type, rest) = take_token(rest).context("missing attribute type")?; Ok((name, value_type, rest.trim_start())) } fn take_token(input: &str) -> Option<(&str, &str)> { let input = input.trim_start(); if input.is_empty() { return None; } let end = input.find(char::is_whitespace).unwrap_or(input.len()); Some((&input[..end], &input[end..])) } fn print_certdata_summary(doc: &CertDataDocument, limit: usize) { let mut class_counts: BTreeMap = BTreeMap::new(); let mut multiline_attrs = 0usize; for obj in &doc.objects { let class = obj .inline_attr_value("CKA_CLASS") .unwrap_or("UNKNOWN") .to_string(); *class_counts.entry(class).or_default() += 1; multiline_attrs += obj .attributes .iter() .filter(|attr| matches!(attr.value, CertDataValue::Multiline(_))) .count(); } println!( "certdata revision: {}", doc.revision.as_deref().unwrap_or("") ); println!("objects: {}", doc.objects.len()); println!("multiline attributes: {}", multiline_attrs); println!("classes:"); for (class, count) in class_counts { println!(" {class}: {count}"); } if limit == 0 { return; } println!("sample objects (up to {limit}):"); for (idx, obj) in doc.objects.iter().take(limit).enumerate() { let class = obj.inline_attr_value("CKA_CLASS").unwrap_or("UNKNOWN"); let label = obj .inline_attr_value("CKA_LABEL") .map(certdata_inline_unquote) .unwrap_or_else(|| "".to_string()); println!( " {}. class={} label={} attrs={}", idx + 1, class, label, obj.attributes.len() ); } } fn certdata_inline_unquote(value: &str) -> String { let trimmed = value.trim(); if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { trimmed[1..trimmed.len() - 1].replace("\\\"", "\"") } else { trimmed.to_string() } } fn ensure_pem_certificate(bytes: &[u8]) -> Result<()> { // Keep validation intentionally simple: we only require a PEM certificate block. let text = std::str::from_utf8(bytes).context("certificate is not valid UTF-8 PEM text")?; if !text.contains("-----BEGIN CERTIFICATE-----") || !text.contains("-----END CERTIFICATE-----") { bail!("certificate must be PEM-encoded and contain a CERTIFICATE block"); } Ok(()) } fn sanitize_name(input: &str) -> String { // Restrict filenames to portable characters to avoid odd trust-source names. input .chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' } }) .collect::() .trim_matches('.') .to_string() } fn install_file(dest_file: &Path, bytes: &[u8], force: bool, dry_run: bool) -> Result<()> { // Avoid rewriting identical content to keep runs idempotent. if let Ok(existing) = fs::read(dest_file) { if existing == bytes { println!("File already exists with identical contents."); return Ok(()); } if !force { bail!( "destination already exists with different contents: {} (use --force to overwrite)", dest_file.display() ); } println!("Overwriting existing file (`--force`)."); } if dry_run { println!("[dry-run] Would write {}", dest_file.display()); return Ok(()); } let parent = dest_file .parent() .context("destination path has no parent directory")?; fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; let file_name = dest_file .file_name() .and_then(OsStr::to_str) .unwrap_or("temp.pem"); // Write atomically via a temporary file in the same directory. let tmp_file = parent.join(format!(".{file_name}.tmp")); fs::write(&tmp_file, bytes) .with_context(|| format!("failed to write temporary file {}", tmp_file.display()))?; #[cfg(unix)] { fs::set_permissions(&tmp_file, fs::Permissions::from_mode(0o644)) .with_context(|| format!("failed to set permissions on {}", tmp_file.display()))?; } fs::rename(&tmp_file, dest_file).with_context(|| { format!( "failed to move {} into place at {}", tmp_file.display(), dest_file.display() ) })?; println!("Installed {}", dest_file.display()); Ok(()) } fn extract_trust(root: &Path, output_target: &Path, dry_run: bool) -> Result<()> { let output_host = path_in_root(root, output_target); println!( "Extract destination (target path): {}", output_target.display() ); println!("Extract destination (host path): {}", output_host.display()); if dry_run { println!("[dry-run] Would create {}", output_host.display()); } else { fs::create_dir_all(&output_host) .with_context(|| format!("failed to create {}", output_host.display()))?; } // Generate each derived output from the shared p11-kit trust database. for job in EXTRACT_JOBS { let dest_target = output_target.join(job.relative_dest); println!( "trust extract: --format={} --filter={} -> {}", job.format, job.filter, dest_target.display() ); run_trust_extract_job(root, job, &dest_target, dry_run)?; } // Match `update-ca-trust` behavior only for the standard extraction destination. if output_target == Path::new(DEFAULT_EXTRACTED_DIR) { sync_system_ssl_symlinks(root, output_target, dry_run)?; } else { println!("Skipping /etc/ssl/certs symlink sync for custom output directory."); } Ok(()) } fn run_trust_extract_job( root: &Path, job: &ExtractJob, dest_target: &Path, dry_run: bool, ) -> Result<()> { // Build the `trust extract` invocation exactly from the job description. let mut args = vec![ "extract".to_string(), "--overwrite".to_string(), format!("--format={}", job.format), format!("--filter={}", job.filter), ]; if job.comment { args.push("--comment".to_string()); } if let Some(purpose) = job.purpose { args.push(format!("--purpose={purpose}")); } args.push(dest_target.display().to_string()); run_target_command( root, "trust", &args, dry_run, &[("P11_KIT_NO_USER_CONFIG", "1")], ) .context("trust extract failed") } fn sync_system_ssl_symlinks(root: &Path, extracted_target: &Path, dry_run: bool) -> Result<()> { let ssl_certs_host = path_in_root(root, Path::new(DEFAULT_SSL_CERTS_DIR)); let cert_pem_link_host = path_in_root(root, Path::new(DEFAULT_SSL_CERT_PEM_LINK)); let ca_bundle_link_host = path_in_root(root, Path::new(DEFAULT_SSL_CA_CERTIFICATES_BUNDLE_LINK)); let ca_bundle_crt_link_host = path_in_root(root, Path::new(DEFAULT_SSL_CA_BUNDLE_CRT_LINK)); let java_link_host = path_in_root(root, Path::new(DEFAULT_JAVA_CACERTS_LINK)); let pki_ca_bundle_crt_link_host = path_in_root(root, Path::new(DEFAULT_PKI_TLS_CA_BUNDLE_CRT_LINK)); let pki_ca_bundle_trust_crt_link_host = path_in_root(root, Path::new(DEFAULT_PKI_TLS_CA_BUNDLE_TRUST_CRT_LINK)); let pki_java_link_host = path_in_root(root, Path::new(DEFAULT_PKI_TLS_JAVA_CACERTS_LINK)); let tls_bundle_src_host = path_in_root(root, &extracted_target.join("tls-ca-bundle.pem")); let trust_bundle_src_host = path_in_root(root, &extracted_target.join("ca-bundle.trust.crt")); let cadir_host = path_in_root(root, &extracted_target.join("cadir")); let java_src_host = path_in_root(root, &extracted_target.join("java-cacerts.jks")); println!("Syncing /etc/ssl/certs links from {}", cadir_host.display()); if dry_run { println!("[dry-run] Would ensure {}", ssl_certs_host.display()); println!( "[dry-run] Would link files from {} into {}", cadir_host.display(), ssl_certs_host.display() ); println!( "[dry-run] Would delete broken symlinks in {}", ssl_certs_host.display() ); println!( "[dry-run] Would link {} -> {}", cert_pem_link_host.display(), tls_bundle_src_host.display() ); println!( "[dry-run] Would link {} -> {}", ca_bundle_link_host.display(), tls_bundle_src_host.display() ); println!( "[dry-run] Would link {} -> {}", ca_bundle_crt_link_host.display(), tls_bundle_src_host.display() ); println!( "[dry-run] Would link {} -> {}", java_link_host.display(), java_src_host.display() ); println!( "[dry-run] Would link {} -> {}", pki_ca_bundle_crt_link_host.display(), tls_bundle_src_host.display() ); println!( "[dry-run] Would link {} -> {}", pki_ca_bundle_trust_crt_link_host.display(), trust_bundle_src_host.display() ); println!( "[dry-run] Would link {} -> {}", pki_java_link_host.display(), java_src_host.display() ); return Ok(()); } fs::create_dir_all(&ssl_certs_host) .with_context(|| format!("failed to create {}", ssl_certs_host.display()))?; let java_parent = java_link_host .parent() .context("java cacerts link path has no parent")?; fs::create_dir_all(java_parent) .with_context(|| format!("failed to create {}", java_parent.display()))?; let pki_ca_bundle_parent = pki_ca_bundle_crt_link_host .parent() .context("pki ca-bundle.crt link path has no parent")?; fs::create_dir_all(pki_ca_bundle_parent) .with_context(|| format!("failed to create {}", pki_ca_bundle_parent.display()))?; let pki_ca_bundle_trust_parent = pki_ca_bundle_trust_crt_link_host .parent() .context("pki ca-bundle.trust.crt link path has no parent")?; fs::create_dir_all(pki_ca_bundle_trust_parent) .with_context(|| format!("failed to create {}", pki_ca_bundle_trust_parent.display()))?; let pki_java_parent = pki_java_link_host .parent() .context("pki java cacerts link path has no parent")?; fs::create_dir_all(pki_java_parent) .with_context(|| format!("failed to create {}", pki_java_parent.display()))?; // Link every extracted hash file into `/etc/ssl/certs`, then prune stale links. for entry in fs::read_dir(&cadir_host) .with_context(|| format!("failed to read {}", cadir_host.display()))? { let entry = entry?; let source = entry.path(); let link_path = ssl_certs_host.join(entry.file_name()); replace_symlink(&source, &link_path)?; } remove_broken_symlinks(&ssl_certs_host)?; replace_symlink(&tls_bundle_src_host, &cert_pem_link_host)?; replace_symlink(&tls_bundle_src_host, &ca_bundle_link_host)?; replace_symlink(&tls_bundle_src_host, &ca_bundle_crt_link_host)?; replace_symlink(&java_src_host, &java_link_host)?; replace_symlink(&tls_bundle_src_host, &pki_ca_bundle_crt_link_host)?; replace_symlink(&trust_bundle_src_host, &pki_ca_bundle_trust_crt_link_host)?; replace_symlink(&java_src_host, &pki_java_link_host)?; Ok(()) } fn remove_broken_symlinks(dir: &Path) -> Result<()> { // `update-ca-trust` prunes stale links after re-linking the extracted directory. for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { let entry = entry?; let path = entry.path(); let meta = fs::symlink_metadata(&path) .with_context(|| format!("failed to stat {}", path.display()))?; if !meta.file_type().is_symlink() { continue; } if fs::metadata(&path).is_err() { fs::remove_file(&path) .with_context(|| format!("failed to remove broken symlink {}", path.display()))?; } } Ok(()) } fn replace_symlink(source: &Path, link_path: &Path) -> Result<()> { let parent = link_path .parent() .context("link path has no parent directory")?; let rel_target = make_relative_path(parent, source); // Replace pre-existing files/symlinks, but never clobber a real directory. if let Ok(meta) = fs::symlink_metadata(link_path) { if meta.is_dir() && !meta.file_type().is_symlink() { bail!( "refusing to replace directory with symlink: {}", link_path.display() ); } fs::remove_file(link_path) .with_context(|| format!("failed to remove {}", link_path.display()))?; } symlink(&rel_target, link_path).with_context(|| { format!( "failed to create symlink {} -> {}", link_path.display(), rel_target.display() ) })?; Ok(()) } fn make_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf { // Prefer relative symlinks so extracted trees stay relocatable under a rootfs. for (depth, ancestor) in from_dir.ancestors().enumerate() { if let Ok(suffix) = to_path.strip_prefix(ancestor) { let mut rel = PathBuf::new(); for _ in 0..depth { rel.push(".."); } rel.push(suffix); return rel; } } to_path.to_path_buf() } fn run_target_command( root: &Path, program: &str, args: &[String], dry_run: bool, envs: &[(&str, &str)], ) -> Result<()> { let resolved_program = resolve_target_program(root, program); let resolved_program_display = resolved_program.display().to_string(); if dry_run { print_command_preview(root, &resolved_program_display, args, envs); return Ok(()); } // Execute directly on the host or via chroot for image builds. let mut cmd = if is_root_fs(root) { let mut cmd = Command::new(&resolved_program); cmd.args(args); cmd } else { let mut cmd = Command::new("chroot"); cmd.arg(root).arg(&resolved_program).args(args); cmd }; // Prevent `trust` from loading user-level configuration. for (k, v) in envs { cmd.env(k, v); } // `Command::output()` defaults stdin to `/dev/null`. Minimal chroots may // not have device nodes yet, so inherit stdin while still capturing output. cmd.stdin(Stdio::inherit()); let output = cmd.output().map_err(|err| { command_spawn_error(root, &resolved_program, &resolved_program_display, err) })?; if output.status.success() { if !output.stdout.is_empty() { print!("{}", String::from_utf8_lossy(&output.stdout)); } if !output.stderr.is_empty() { eprint!("{}", String::from_utf8_lossy(&output.stderr)); } return Ok(()); } let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let mut details = Vec::new(); if !stdout.is_empty() { details.push(format!("stdout: {stdout}")); } if !stderr.is_empty() { details.push(format!("stderr: {stderr}")); } let suffix = if details.is_empty() { String::new() } else { format!("\n{}", details.join("\n")) }; bail!( "command exited with status {}{}", output .status .code() .map(|c| c.to_string()) .unwrap_or_else(|| "signal".to_string()), suffix ); } fn command_spawn_error( root: &Path, resolved_program: &Path, resolved_program_display: &str, err: std::io::Error, ) -> anyhow::Error { let mut message = if is_root_fs(root) { format!("failed to execute `{resolved_program_display}`: {err}") } else { format!( "failed to execute `chroot {} {}`: {err}", root.display(), resolved_program_display ) }; if err.kind() == ErrorKind::NotFound { message.push_str(&exec_not_found_diagnostics(root, resolved_program)); } anyhow!(message) } fn exec_not_found_diagnostics(root: &Path, resolved_program: &Path) -> String { let program_host_path = path_in_root(root, resolved_program); let mut details = vec![ String::new(), "exec diagnostics for ENOENT:".to_string(), format!(" target program path: {}", resolved_program.display()), format!(" host-visible path: {}", program_host_path.display()), ]; match fs::symlink_metadata(&program_host_path) { Ok(meta) => { details.push(format!( " target exists: yes (mode {:o}, {} bytes)", meta.permissions().mode() & 0o7777, meta.len() )); } Err(stat_err) => { details.push(format!(" target exists: no ({stat_err})")); return details.join("\n"); } } match fs::read(&program_host_path) { Ok(bytes) => { if let Some(interpreter) = shebang_interpreter(&bytes) { details.push(format!(" script interpreter: {interpreter}")); details.push(format!( " script interpreter exists: {}", path_existence_description(root, Path::new(&interpreter)) )); } else if is_elf(&bytes) { details.push(" file format: ELF".to_string()); match elf_interpreter(&bytes) { Some(interpreter) => { details.push(format!(" ELF interpreter: {interpreter}")); details.push(format!( " ELF interpreter exists: {}", path_existence_description(root, Path::new(&interpreter)) )); } None => details.push(" ELF interpreter: none found".to_string()), } } else { details.push(" file format: not ELF and no shebang".to_string()); } } Err(read_err) => details.push(format!(" failed to read target: {read_err}")), } details.join("\n") } fn path_existence_description(root: &Path, target_path: &Path) -> String { if !target_path.is_absolute() { return "not checked (interpreter path is not absolute)".to_string(); } let host_path = path_in_root(root, target_path); match fs::symlink_metadata(&host_path) { Ok(meta) => format!( "yes at {} (mode {:o}, {} bytes)", host_path.display(), meta.permissions().mode() & 0o7777, meta.len() ), Err(err) => format!("no at {} ({err})", host_path.display()), } } fn shebang_interpreter(bytes: &[u8]) -> Option { if !bytes.starts_with(b"#!") { return None; } let line_end = bytes .iter() .position(|b| *b == b'\n') .unwrap_or(bytes.len()); let line = std::str::from_utf8(&bytes[2..line_end]).ok()?.trim(); let interpreter = line.split_whitespace().next()?; Some(interpreter.to_string()) } fn is_elf(bytes: &[u8]) -> bool { bytes.starts_with(b"\x7fELF") } fn elf_interpreter(bytes: &[u8]) -> Option { if !is_elf(bytes) || bytes.len() < 16 { return None; } let class = bytes[4]; let data = bytes[5]; let little_endian = match data { 1 => true, 2 => false, _ => return None, }; let (phoff, phentsize, phnum) = match class { 1 => ( read_u32(bytes, 28, little_endian)? as usize, read_u16(bytes, 42, little_endian)? as usize, read_u16(bytes, 44, little_endian)? as usize, ), 2 => ( read_u64(bytes, 32, little_endian)? as usize, read_u16(bytes, 54, little_endian)? as usize, read_u16(bytes, 56, little_endian)? as usize, ), _ => return None, }; for index in 0..phnum { let offset = phoff.checked_add(index.checked_mul(phentsize)?)?; let header = bytes.get(offset..offset.checked_add(phentsize)?)?; let p_type = read_u32(header, 0, little_endian)?; if p_type != 3 { continue; } let (interp_offset, interp_size) = match class { 1 => ( read_u32(header, 4, little_endian)? as usize, read_u32(header, 16, little_endian)? as usize, ), 2 => ( read_u64(header, 8, little_endian)? as usize, read_u64(header, 32, little_endian)? as usize, ), _ => return None, }; let interp_end = interp_offset.checked_add(interp_size)?; let raw = bytes.get(interp_offset..interp_end)?; let nul = raw.iter().position(|b| *b == 0).unwrap_or(raw.len()); return std::str::from_utf8(&raw[..nul]).ok().map(ToOwned::to_owned); } None } fn read_u16(bytes: &[u8], offset: usize, little_endian: bool) -> Option { let raw: [u8; 2] = bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?; Some(if little_endian { u16::from_le_bytes(raw) } else { u16::from_be_bytes(raw) }) } fn read_u32(bytes: &[u8], offset: usize, little_endian: bool) -> Option { let raw: [u8; 4] = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?; Some(if little_endian { u32::from_le_bytes(raw) } else { u32::from_be_bytes(raw) }) } fn read_u64(bytes: &[u8], offset: usize, little_endian: bool) -> Option { let raw: [u8; 8] = bytes.get(offset..offset.checked_add(8)?)?.try_into().ok()?; Some(if little_endian { u64::from_le_bytes(raw) } else { u64::from_be_bytes(raw) }) } fn resolve_target_program(root: &Path, program: &str) -> PathBuf { let path_env = std::env::var_os("PATH"); resolve_target_program_with_path(root, program, path_env.as_deref()) } fn resolve_target_program_with_path( root: &Path, program: &str, path_env: Option<&OsStr>, ) -> PathBuf { if program.contains('/') { return PathBuf::from(program); } for candidate in target_command_search_candidates(program, path_env) { if path_in_root(root, &candidate).exists() { return candidate; } } PathBuf::from(program) } fn target_command_search_candidates(program: &str, path_env: Option<&OsStr>) -> Vec { let mut candidates = Vec::new(); if let Some(path_env) = path_env { for dir in std::env::split_paths(path_env).filter(|dir| dir.is_absolute()) { candidates.push(dir.join(program)); } } for dir in TARGET_COMMAND_FALLBACK_DIRS { let candidate = Path::new(dir).join(program); if !candidates.iter().any(|existing| existing == &candidate) { candidates.push(candidate); } } candidates } fn print_command_preview(root: &Path, program: &str, args: &[String], envs: &[(&str, &str)]) { // Keep dry-run output close to the real shell command for easier debugging. let env_prefix = envs .iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(" "); let arg_string = if args.is_empty() { String::new() } else { format!(" {}", args.join(" ")) }; if is_root_fs(root) { if env_prefix.is_empty() { println!("[dry-run] Would run: {program}{arg_string}"); } else { println!("[dry-run] Would run: {env_prefix} {program}{arg_string}"); } } else if env_prefix.is_empty() { println!( "[dry-run] Would run: chroot {} {}{}", root.display(), program, arg_string ); } else { println!( "[dry-run] Would run: {} chroot {} {}{}", env_prefix, root.display(), program, arg_string ); } } fn path_in_root(root: &Path, target_path: &Path) -> PathBuf { // Interpret absolute target paths as paths inside the selected rootfs. if target_path.is_absolute() { root.join(target_path.strip_prefix("/").unwrap_or(target_path)) } else { root.join(target_path) } } fn is_root_fs(root: &Path) -> bool { root == Path::new(DEFAULT_ROOT) } #[cfg(test)] mod tests { use super::*; const SAMPLE_CERTDATA: &str = r#" # Revision:20250604 BEGINDATA CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE CKA_LABEL UTF8 "Example Root CA" CKA_VALUE MULTILINE_OCTAL \060\202 \001\002 END CKA_CLASS CK_OBJECT_CLASS CKO_NSS_TRUST CKA_LABEL UTF8 "Example Root CA" CKA_TRUST_SERVER_AUTH CK_TRUST CKT_NSS_TRUSTED_DELEGATOR "#; #[test] fn pem_validation_accepts_basic_cert_block() { let pem = b"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"; assert!(ensure_pem_certificate(pem).is_ok()); } #[test] fn pem_validation_rejects_non_pem() { let err = ensure_pem_certificate(b"not a certificate").unwrap_err(); assert!(err.to_string().contains("PEM-encoded")); } #[test] fn pem_validation_rejects_non_utf8() { let err = ensure_pem_certificate(&[0xff, 0xfe, 0xfd]).unwrap_err(); assert!(err.to_string().contains("UTF-8")); } #[test] fn sanitize_name_replaces_invalid_chars_and_trims_dots() { assert_eq!( sanitize_name("..ACME Root CA (prod).."), "ACME_Root_CA__prod_" ); } #[test] fn sanitize_name_keeps_safe_chars() { assert_eq!(sanitize_name("ca-root_v1.2-3"), "ca-root_v1.2-3"); } #[test] fn relative_path_same_parent() { let from = Path::new("/etc/ssl/certs"); let to = Path::new("/etc/ssl/certs/abc.pem"); assert_eq!(make_relative_path(from, to), PathBuf::from("abc.pem")); } #[test] fn relative_path_across_siblings() { let from = Path::new("/etc/ssl/certs/java"); let to = Path::new("/etc/ca-certificates/extracted/java-cacerts.jks"); assert_eq!( make_relative_path(from, to), PathBuf::from("../../../ca-certificates/extracted/java-cacerts.jks") ); } #[test] fn path_in_root_maps_absolute_target() { assert_eq!( path_in_root(Path::new("/mnt/rootfs"), Path::new("/etc/ssl/certs")), PathBuf::from("/mnt/rootfs/etc/ssl/certs") ); } #[test] fn path_in_root_maps_relative_target() { assert_eq!( path_in_root(Path::new("/mnt/rootfs"), Path::new("custom/out")), PathBuf::from("/mnt/rootfs/custom/out") ); } #[test] fn root_detection_matches_default_only() { assert!(is_root_fs(Path::new("/"))); assert!(!is_root_fs(Path::new("/mnt/rootfs"))); } #[test] fn target_command_candidates_use_absolute_path_entries_and_fallbacks() { let candidates = target_command_search_candidates( "trust", Some(std::ffi::OsStr::new("/custom/bin:relative:/usr/bin")), ); assert_eq!(candidates[0], PathBuf::from("/custom/bin/trust")); assert_eq!(candidates[1], PathBuf::from("/usr/bin/trust")); assert!(candidates.contains(&PathBuf::from("/bin/trust"))); assert!(!candidates.contains(&PathBuf::from("relative/trust"))); } #[test] fn resolve_target_program_finds_binary_inside_selected_root() { let root = unique_test_dir("target-command-root"); let trust_host_path = root.join("custom/bin/trust"); fs::create_dir_all(trust_host_path.parent().unwrap()).unwrap(); fs::write(&trust_host_path, b"").unwrap(); let resolved = resolve_target_program_with_path( &root, "trust", Some(std::ffi::OsStr::new("/custom/bin")), ); fs::remove_dir_all(&root).unwrap(); assert_eq!(resolved, PathBuf::from("/custom/bin/trust")); } #[test] fn resolve_target_program_leaves_missing_program_unqualified() { let root = unique_test_dir("target-command-missing"); fs::create_dir_all(&root).unwrap(); let resolved = resolve_target_program_with_path(&root, "missing-tool", Some(std::ffi::OsStr::new(""))); fs::remove_dir_all(&root).unwrap(); assert_eq!(resolved, PathBuf::from("missing-tool")); } #[test] fn shebang_interpreter_reads_first_command() { let script = b"#!/usr/bin/env sh\nexit 0\n"; assert_eq!(shebang_interpreter(script).as_deref(), Some("/usr/bin/env")); } #[test] fn elf_interpreter_reads_64_bit_pt_interp() { let interpreter = b"/lib/ld-musl-x86_64.so.1\0"; let mut elf = vec![0u8; 160]; elf[0..4].copy_from_slice(b"\x7fELF"); elf[4] = 2; // 64-bit elf[5] = 1; // little-endian elf[32..40].copy_from_slice(&64u64.to_le_bytes()); // e_phoff elf[54..56].copy_from_slice(&56u16.to_le_bytes()); // e_phentsize elf[56..58].copy_from_slice(&1u16.to_le_bytes()); // e_phnum elf[64..68].copy_from_slice(&3u32.to_le_bytes()); // PT_INTERP elf[72..80].copy_from_slice(&128u64.to_le_bytes()); // p_offset elf[96..104].copy_from_slice(&(interpreter.len() as u64).to_le_bytes()); // p_filesz elf[128..128 + interpreter.len()].copy_from_slice(interpreter); assert_eq!( elf_interpreter(&elf).as_deref(), Some("/lib/ld-musl-x86_64.so.1") ); } fn unique_test_dir(name: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); std::env::temp_dir().join(format!("ca-certs-{name}-{}-{nonce}", std::process::id())) } #[test] fn certdata_parser_extracts_revision_and_objects() { let doc = parse_certdata(SAMPLE_CERTDATA).unwrap(); assert_eq!(doc.revision.as_deref(), Some("20250604")); assert_eq!(doc.objects.len(), 2); assert_eq!( doc.objects[0].inline_attr_value("CKA_CLASS"), Some("CKO_CERTIFICATE") ); assert_eq!( doc.objects[1].inline_attr_value("CKA_CLASS"), Some("CKO_NSS_TRUST") ); } #[test] fn certdata_parser_handles_multiline_attributes() { let doc = parse_certdata(SAMPLE_CERTDATA).unwrap(); let attr = doc.objects[0].attr("CKA_VALUE").unwrap(); match &attr.value { CertDataValue::Multiline(lines) => { assert_eq!( lines, &vec![r"\060\202".to_string(), r"\001\002".to_string()] ); } other => panic!("expected multiline value, got {other:?}"), } } #[test] fn certdata_parser_rejects_unterminated_multiline() { let input = "CKA_VALUE MULTILINE_OCTAL\n\\060\\202\n"; let err = parse_certdata(input).unwrap_err(); assert!(err.to_string().contains("unterminated multiline")); } #[test] fn certdata_inline_unquote_removes_quotes() { assert_eq!(certdata_inline_unquote("\"Test CA\""), "Test CA"); assert_eq!( certdata_inline_unquote("CKO_CERTIFICATE"), "CKO_CERTIFICATE" ); } #[test] fn derive_hg_log_url_from_raw_file_url() { let raw = "https://hg.mozilla.org/projects/nss/raw-file/tip/lib/ckfw/builtins/certdata.txt"; assert_eq!( derive_hg_log_url(raw).as_deref(), Some("https://hg.mozilla.org/projects/nss/log/tip/lib/ckfw/builtins/certdata.txt") ); } #[test] fn derive_hg_log_url_returns_none_for_non_hg_raw_url() { assert!(derive_hg_log_url("https://example.com/certdata.txt").is_none()); } #[test] fn extract_hg_revision_from_log_html_matches_make_ca_style_line() { let html = r#" x 76e6887ecc1a5410233ad9c5f4cadae4e298a37b
created 2026-02-06 10:55 +0000 "#; assert_eq!( extract_hg_revision_from_log_html(html).unwrap(), "76e6887ecc1a5410233ad9c5f4cadae4e298a37b" ); } #[test] fn extract_certdata_revision_from_text_reads_header() { let text = "# Revision:abc123\n# certdata\n"; assert_eq!( extract_certdata_revision_from_text(text).as_deref(), Some("abc123") ); } #[test] fn normalize_certdata_download_inserts_and_replaces_revision_line() { let raw = "# Revision:old\n# certdata\nBEGINDATA\n"; let out = normalize_certdata_download(raw, Some("newrev".to_string())); assert!(out.starts_with("# Revision:newrev\n")); assert!(!out.contains("# Revision:old")); assert!(out.contains("BEGINDATA")); } #[test] fn openssl_s_client_args_use_portable_ca_file_flag() { let args = openssl_s_client_args(Path::new("/tmp/bootstrap-ca.pem"), "hg.mozilla.org", 443); assert!(args.contains(&"-CAfile".to_string())); assert!(!args.contains(&"-verifyCAfile".to_string())); assert!(!args.contains(&"-verifyCApath".to_string())); } #[test] fn openssl_s_client_capath_args_use_libressl_portable_flag() { let args = openssl_s_client_capath_args(Path::new("/etc/ssl/certs")); assert_eq!(args, vec!["-CApath", "/etc/ssl/certs"]); assert!(!args.contains(&"-verifyCApath".to_string())); } #[test] fn decode_certdata_octal_multiline_decodes_bytes() { let lines = vec![r"\101\102".to_string(), r"\103".to_string()]; let bytes = decode_certdata_octal_multiline(&lines).unwrap(); assert_eq!(bytes, b"ABC"); } #[test] fn sync_certdata_output_respects_explicit_path() { let requested = PathBuf::from("/var/tmp/custom-certdata.txt"); let (resolved, cleanup) = resolve_sync_certdata_output(Some(requested.clone()), false).unwrap(); assert_eq!(resolved, requested); assert!(cleanup.is_none()); } #[test] fn sync_certdata_output_uses_tmp_workspace_and_cleans_it() { let (resolved, cleanup) = resolve_sync_certdata_output(None, false).unwrap(); assert!(resolved.starts_with(Path::new("/tmp"))); assert_eq!( resolved.file_name(), Some(OsStr::new(DEFAULT_CERTDATA_OUTPUT)) ); let workspace = resolved.parent().unwrap().to_path_buf(); assert!(workspace.exists()); drop(cleanup); assert!(!workspace.exists()); } #[test] fn sync_certdata_output_dry_run_uses_tmp_path_without_creating_workspace() { let (resolved, cleanup) = resolve_sync_certdata_output(None, true).unwrap(); assert!(resolved.starts_with(Path::new("/tmp"))); assert_eq!( resolved.file_name(), Some(OsStr::new(DEFAULT_CERTDATA_OUTPUT)) ); assert!(cleanup.is_none()); } #[test] fn percent_encode_bytes_encodes_lower_hex() { assert_eq!(percent_encode_bytes(&[0x00, 0x2a, 0xff]), "%00%2a%ff"); } #[test] fn p11_trust_tuple_trusted_server_auth_matches_make_ca_constant() { let trust = MozillaTrustEntry { server_auth: TrustUsage::Trusted, email: TrustUsage::Neutral, code_signing: TrustUsage::Neutral, }; let (trust_line, oid, value) = p11_trust_tuple(&trust).unwrap(); assert_eq!(trust_line, "trusted: true"); assert_eq!(oid, "2.5.29.37"); assert!(value.contains("%07%03%01")); } #[test] fn p11_trust_tuple_any_distrust_uses_x_distrusted() { let trust = MozillaTrustEntry { server_auth: TrustUsage::Neutral, email: TrustUsage::Distrusted, code_signing: TrustUsage::Neutral, }; let (trust_line, oid, _) = p11_trust_tuple(&trust).unwrap(); assert_eq!(trust_line, "x-distrusted: true"); assert_eq!(oid, "1.3.6.1.4.1.3319.6.10.1"); } }