Files
ca-certs/src/main.rs
T

2784 lines
92 KiB
Rust

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<String>,
/// 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<PathBuf>,
#[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<PathBuf>,
#[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<String>,
/// 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<PathBuf>,
/// 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<String>,
/// 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<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)]
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<PathBuf>,
/// 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<CompletionShell>,
/// 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<String>,
objects: Vec<CertDataObject>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CertDataObject {
attributes: Vec<CertDataAttribute>,
}
#[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<String>),
}
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<PathBuf>,
dry_run: bool,
) -> Result<(PathBuf, Option<TempWorkspaceCleanup>)> {
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<PathBuf> {
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: <unavailable for this URL format>; 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<String> {
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<String> {
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<String> {
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<String> {
vec!["-CApath".to_string(), ca_path.display().to_string()]
}
fn write_bootstrap_ca_tempfile() -> Result<PathBuf> {
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<String> {
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::<u16>()
.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<Vec<u8>> {
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<reqwest::blocking::Client> {
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<String> {
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<String> {
// make-ca grabs the first `<i>` line and takes the text before `<`, which is the changeset id.
for line in html.lines() {
let line = line.trim();
if !line.contains("<i>") {
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<Option<String>> {
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<String> {
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>) -> 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<u8>,
mozilla_policy: bool,
server_distrust_after: Option<String>,
email_distrust_after: Option<String>,
}
#[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<String> = 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<PathBuf> {
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<usize> {
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<String> {
// Build separate maps for certificate payloads and trust metadata, then join by issuer+serial.
let mut certs = Vec::new();
let mut trusts: HashMap<String, MozillaTrustEntry> = 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<Option<MozillaCertEntry>> {
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(|| "<unnamed>".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<Option<(String, MozillaTrustEntry)>> {
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<Option<String>> {
// 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<bool> {
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<Option<String>> {
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<Vec<u8>> {
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<u8>) -> 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<String> {
// 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<String> {
// 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<CertDataDocument> {
let mut revision = None;
let mut objects = Vec::new();
let mut current_attrs = Vec::new();
let mut pending_multiline: Option<(String, String, Vec<String>)> = 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<CertDataObject>,
current_attrs: &mut Vec<CertDataAttribute>,
) {
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<String, usize> = 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("<missing>")
);
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(|| "<no label>".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::<String>()
.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<String> {
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<String> {
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<u16> {
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<u32> {
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<u64> {
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<PathBuf> {
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::<Vec<_>>()
.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#"
<tr><td class="age">x</td></tr>
76e6887ecc1a5410233ad9c5f4cadae4e298a37b<br/>created <i>2026-02-06 10:55 +0000</i>
"#;
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");
}
}