2784 lines
92 KiB
Rust
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");
|
|
}
|
|
}
|