use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context as _, Result, bail};
use tempfile::TempDir;
const HARNESS_COSIGN_PASSWORD: &str = "anodize-harness";
const HARNESS_GPG_BATCH_TEMPLATE: &str = "%no-protection
Key-Type: EDDSA
Key-Curve: ed25519
Subkey-Type: EDDSA
Subkey-Curve: ed25519
Name-Real: Anodize Harness
Name-Email: harness@anodize.invalid
Creation-Date: {creation}
Expire-Date: 0
%commit
";
pub struct EphemeralSigningKeys {
pub cosign_key_contents: String,
pub cosign_password: String,
pub gnupg_home: PathBuf,
pub gpg_fingerprint: String,
pub gpg_key_path: PathBuf,
pub apk_key_path: Option<PathBuf>,
_tmpdir: TempDir,
}
#[cfg(feature = "test-helpers")]
impl EphemeralSigningKeys {
pub fn for_test(apk_key_path: Option<PathBuf>) -> Self {
let tmpdir = tempfile::Builder::new()
.prefix("agpg-test-")
.tempdir()
.expect("for_test: create tempdir");
Self {
cosign_key_contents: "TEST-COSIGN-KEY".into(),
cosign_password: HARNESS_COSIGN_PASSWORD.into(),
gnupg_home: tmpdir.path().join("gnupg"),
gpg_fingerprint: "TESTFPR0000000000".into(),
gpg_key_path: tmpdir.path().join("anodize-harness.asc"),
apk_key_path,
_tmpdir: tmpdir,
}
}
}
pub fn provision_ephemeral_keys(sde: i64) -> Result<EphemeralSigningKeys> {
let root: PathBuf = if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
PathBuf::from("/tmp")
} else {
std::env::temp_dir()
};
let tmpdir = tempfile::Builder::new()
.prefix("agpg-")
.tempdir_in(&root)
.context("harness signing: create tempdir")?;
let cosign_key_contents = provision_cosign(tmpdir.path())?;
let (gnupg_home, gpg_fingerprint, gpg_key_path) = provision_gpg(tmpdir.path(), sde)?;
let apk_key_path = provision_apk(tmpdir.path())?;
Ok(EphemeralSigningKeys {
cosign_key_contents,
cosign_password: HARNESS_COSIGN_PASSWORD.into(),
gnupg_home,
gpg_fingerprint,
gpg_key_path,
apk_key_path,
_tmpdir: tmpdir,
})
}
pub fn path_for_subprocess_env(path: &Path) -> String {
let raw = path.to_string_lossy().into_owned();
if !cfg!(windows) {
return raw;
}
let forward = crate::util::normalize_path_separators(&raw);
#[cfg(windows)]
{
if gpg_on_path_is_msys() {
return to_msys_drive_form(&forward);
}
}
forward
}
#[cfg(any(windows, test))]
fn to_msys_drive_form(forward: &str) -> String {
let mut chars = forward.chars();
match (chars.next(), chars.next(), chars.next()) {
(Some(drive), Some(':'), Some('/')) if drive.is_ascii_alphabetic() => {
format!("/{}/{}", drive.to_ascii_lowercase(), chars.as_str())
}
_ => forward.to_string(),
}
}
#[cfg(windows)]
fn gpg_on_path_is_msys() -> bool {
use std::sync::OnceLock;
static IS_MSYS: OnceLock<bool> = OnceLock::new();
*IS_MSYS.get_or_init(|| {
std::process::Command::new("gpg")
.arg("--version")
.output()
.ok()
.and_then(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|line| line.trim().strip_prefix("Home:"))
.map(|home| home.trim().starts_with('/'))
})
.unwrap_or(false)
})
}
#[cfg(test)]
mod path_tests {
use super::{path_for_subprocess_env, to_msys_drive_form};
use std::path::Path;
#[test]
fn unix_path_passes_through() {
let out = path_for_subprocess_env(Path::new("/tmp/foo/bar"));
assert_eq!(out, "/tmp/foo/bar");
}
#[test]
fn msys_drive_form_rewrites_drive_root() {
assert_eq!(
to_msys_drive_form("C:/Users/RUNNER~1/AppData/Local/Temp/agpg-x"),
"/c/Users/RUNNER~1/AppData/Local/Temp/agpg-x"
);
assert_eq!(to_msys_drive_form("D:/foo"), "/d/foo");
}
#[test]
fn msys_drive_form_passes_through_posix() {
assert_eq!(
to_msys_drive_form("/already/posix/path"),
"/already/posix/path"
);
}
}
fn provision_cosign(tmpdir: &Path) -> Result<String> {
if Command::new("cosign")
.arg("version")
.current_dir(crate::path_util::probe_dir())
.output()
.map(|o| !o.status.success())
.unwrap_or(true)
{
bail!(
"harness signing: `cosign` not on PATH or failed to run. \
Install cosign (e.g. via `anodizer-action` `install: cosign` \
or system package manager) or drop `sign` from \
`anodizer check determinism --stages=`."
);
}
let output = Command::new("cosign")
.args(["generate-key-pair"])
.current_dir(tmpdir)
.env("COSIGN_PASSWORD", HARNESS_COSIGN_PASSWORD)
.output()
.context("harness signing: spawn `cosign generate-key-pair`")?;
if !output.status.success() {
bail!(
"harness signing: cosign generate-key-pair failed (exit {}):\n{}\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let key_path = tmpdir.join("cosign.key");
let contents = std::fs::read_to_string(&key_path)
.with_context(|| format!("harness signing: read cosign key at {}", key_path.display()))?;
Ok(contents)
}
fn provision_apk(tmpdir: &Path) -> Result<Option<PathBuf>> {
let key_path = tmpdir.join("apk-harness.pem");
let ok = Command::new("openssl")
.args([
"genpkey",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
])
.arg(&key_path)
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return Ok(None);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(&key_path).with_context(|| {
format!(
"harness signing: stat ephemeral apk key {}",
key_path.display()
)
})?;
let mut perms = meta.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&key_path, perms).with_context(|| {
format!(
"harness signing: restrict permissions on ephemeral apk key {}",
key_path.display()
)
})?;
}
Ok(Some(key_path))
}
fn provision_gpg(tmpdir: &Path, sde: i64) -> Result<(PathBuf, String, PathBuf)> {
if Command::new("gpg")
.arg("--version")
.current_dir(crate::path_util::probe_dir())
.output()
.map(|o| !o.status.success())
.unwrap_or(true)
{
bail!(
"harness signing: `gpg` not on PATH or failed to run. \
Install GnuPG (e.g. apt-get install gnupg, brew install gpg, \
choco install gnupg) or drop `sign` from \
`anodizer check determinism --stages=`."
);
}
let gnupg_home = tmpdir.join("gnupg");
std::fs::create_dir(&gnupg_home).context("harness signing: create GNUPGHOME")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&gnupg_home)
.context("harness signing: stat GNUPGHOME")?
.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&gnupg_home, perms)
.context("harness signing: chmod 0700 GNUPGHOME")?;
}
std::fs::write(
gnupg_home.join("gpg-agent.conf"),
"allow-loopback-pinentry\n",
)
.context("harness signing: write gpg-agent.conf")?;
std::fs::write(gnupg_home.join("gpg.conf"), "pinentry-mode loopback\n")
.context("harness signing: write gpg.conf")?;
let _ = Command::new("gpgconf")
.args(["--launch", "gpg-agent"])
.env("GNUPGHOME", path_for_subprocess_env(&gnupg_home))
.output();
let creation_dt = chrono::DateTime::<chrono::Utc>::from_timestamp(sde, 0)
.ok_or_else(|| anyhow::anyhow!("harness signing: SDE {} out of range", sde))?;
let creation_str = creation_dt.format("%Y%m%dT%H%M%S").to_string();
let batch_path = tmpdir.join("gen-key.batch");
let batch_config = HARNESS_GPG_BATCH_TEMPLATE.replace("{creation}", &creation_str);
std::fs::write(&batch_path, &batch_config)
.context("harness signing: write gpg batch-key-gen config")?;
let gen_out = Command::new("gpg")
.args(["--batch", "--gen-key"])
.arg(path_for_subprocess_env(&batch_path))
.env("GNUPGHOME", path_for_subprocess_env(&gnupg_home))
.output()
.context("harness signing: spawn `gpg --batch --gen-key`")?;
if !gen_out.status.success() {
bail!(
"harness signing: gpg --gen-key failed (exit {}):\n{}\n{}",
gen_out.status,
String::from_utf8_lossy(&gen_out.stdout),
String::from_utf8_lossy(&gen_out.stderr)
);
}
let list_out = Command::new("gpg")
.args(["--list-secret-keys", "--with-colons"])
.env("GNUPGHOME", path_for_subprocess_env(&gnupg_home))
.output()
.context("harness signing: spawn `gpg --list-secret-keys`")?;
if !list_out.status.success() {
bail!(
"harness signing: gpg --list-secret-keys failed (exit {}):\n{}",
list_out.status,
String::from_utf8_lossy(&list_out.stderr)
);
}
let stdout = String::from_utf8_lossy(&list_out.stdout);
let fingerprint = parse_fingerprint(&stdout).ok_or_else(|| {
anyhow::anyhow!(
"harness signing: could not parse gpg --list-secret-keys --with-colons output:\n{}",
stdout
)
})?;
let gpg_key_path = tmpdir.join("anodize-harness.asc");
let export_out = Command::new("gpg")
.args(["--batch", "--armor", "--export-secret-keys", &fingerprint])
.env("GNUPGHOME", path_for_subprocess_env(&gnupg_home))
.output()
.context("harness signing: spawn `gpg --export-secret-keys`")?;
if !export_out.status.success() {
bail!(
"harness signing: gpg --export-secret-keys failed (exit {}):\n{}",
export_out.status,
String::from_utf8_lossy(&export_out.stderr)
);
}
std::fs::write(&gpg_key_path, &export_out.stdout).with_context(|| {
format!(
"harness signing: write exported secret key to {}",
gpg_key_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&gpg_key_path)
.context("harness signing: stat exported key")?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&gpg_key_path, perms)
.context("harness signing: chmod 0600 exported key")?;
}
Ok((gnupg_home, fingerprint, gpg_key_path))
}
fn parse_fingerprint(stdout: &str) -> Option<String> {
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix("fpr:") {
let fields: Vec<&str> = rest.split(':').collect();
if let Some(fpr) = fields.get(8)
&& fpr.len() >= 16
{
return Some(fpr.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_fingerprint_extracts_full_fpr() {
let sample = "sec:u:255:22:ABCDEF1234567890ABCDEF1234567890ABCDEF12:1730000000:::u:::scESC:::+:::ed25519:::0:\nfpr:::::::::ABCDEF1234567890ABCDEF1234567890ABCDEF12:\n";
assert_eq!(
parse_fingerprint(sample).as_deref(),
Some("ABCDEF1234567890ABCDEF1234567890ABCDEF12")
);
}
#[test]
fn parse_fingerprint_none_when_no_fpr_record() {
assert!(parse_fingerprint("sec:u:255:22::1730000000:::u::\n").is_none());
}
#[test]
fn parse_fingerprint_skips_short_fpr_field() {
assert!(parse_fingerprint("fpr:::::::::ABCD:\n").is_none());
}
}
#[cfg(all(test, unix))]
mod provision_tests {
use super::*;
use crate::test_helpers::fake_tool::FakeToolDir;
const FAKE_FPR: &str = "ABCDEF1234567890ABCDEF1234567890ABCDEF12";
fn provision_err(sde: i64) -> String {
match provision_ephemeral_keys(sde) {
Ok(_) => panic!("expected provisioning to fail"),
Err(e) => format!("{e:#}"),
}
}
fn stub_cosign_ok(tools: &FakeToolDir) {
tools
.tool("cosign")
.script(
"case \"$1\" in\n\
version) exit 0 ;;\n\
generate-key-pair)\n\
printf '%s' 'FAKE-ENCRYPTED-COSIGN-PEM' > cosign.key\n\
printf '%s' 'FAKE-COSIGN-PUB' > cosign.pub\n\
exit 0 ;;\n\
*) exit 1 ;;\n\
esac",
)
.install();
}
fn stub_openssl_ok(tools: &FakeToolDir) {
tools
.tool("openssl")
.script(
"out=\"\"\n\
while [ $# -gt 0 ]; do\n\
if [ \"$1\" = '-out' ]; then out=\"$2\"; shift; fi\n\
shift\n\
done\n\
if [ -n \"$out\" ]; then printf '%s' '-----BEGIN PRIVATE KEY-----\\nFAKE\\n-----END PRIVATE KEY-----\\n' > \"$out\"; fi\n\
exit 0",
)
.install();
}
fn stub_gpg_ok(tools: &FakeToolDir) {
tools
.tool("gpg")
.script(format!(
"if [ \"$1\" = '--version' ]; then exit 0; fi\n\
if [ \"$1\" = '--list-secret-keys' ]; then printf 'fpr:::::::::{FAKE_FPR}:\\n'; exit 0; fi\n\
if [ \"$2\" = '--gen-key' ]; then exit 0; fi\n\
if [ \"$2\" = '--armor' ]; then printf 'FAKE-ARMORED-SECRET-KEY\\n'; exit 0; fi\n\
exit 9"
))
.install();
tools.tool("gpgconf").install();
}
#[test]
#[serial_test::serial(path_env)]
fn provision_happy_path_yields_keys_and_config() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
stub_gpg_ok(&tools);
stub_openssl_ok(&tools);
let _g = tools.activate();
let keys = provision_ephemeral_keys(1735689600).expect("provision succeeds");
assert_eq!(keys.cosign_key_contents, "FAKE-ENCRYPTED-COSIGN-PEM");
assert_eq!(keys.cosign_password, "anodize-harness");
assert_eq!(keys.gpg_fingerprint, FAKE_FPR);
let apk_key = keys
.apk_key_path
.as_ref()
.expect("openssl stub yields an apk key path");
assert!(apk_key.is_file(), "apk key file must exist");
assert_eq!(
std::fs::read_to_string(&keys.gpg_key_path).unwrap(),
"FAKE-ARMORED-SECRET-KEY\n"
);
assert!(keys.gnupg_home.is_dir());
assert_eq!(
std::fs::read_to_string(keys.gnupg_home.join("gpg-agent.conf")).unwrap(),
"allow-loopback-pinentry\n"
);
assert_eq!(
std::fs::read_to_string(keys.gnupg_home.join("gpg.conf")).unwrap(),
"pinentry-mode loopback\n"
);
{
use std::os::unix::fs::PermissionsExt;
let home_mode = std::fs::metadata(&keys.gnupg_home)
.unwrap()
.permissions()
.mode();
assert_eq!(home_mode & 0o777, 0o700, "GNUPGHOME must be 0700");
let key_mode = std::fs::metadata(&keys.gpg_key_path)
.unwrap()
.permissions()
.mode();
assert_eq!(key_mode & 0o777, 0o600, "exported key must be 0600");
}
let cosign_calls = tools.calls("cosign");
assert_eq!(cosign_calls[0], vec!["version"]);
assert_eq!(cosign_calls[1], vec!["generate-key-pair"]);
let gpg_calls = tools.calls("gpg");
assert_eq!(gpg_calls[0], vec!["--version"]);
assert_eq!(gpg_calls[1][..2], ["--batch", "--gen-key"]);
let batch = std::fs::read_to_string(&gpg_calls[1][2]).unwrap();
assert!(batch.contains("Creation-Date: 20250101T000000"), "{batch}");
assert!(batch.contains("Key-Curve: ed25519"), "{batch}");
assert!(batch.contains("%no-protection"), "{batch}");
assert_eq!(gpg_calls[2], vec!["--list-secret-keys", "--with-colons"]);
assert_eq!(
gpg_calls[3],
vec!["--batch", "--armor", "--export-secret-keys", FAKE_FPR]
);
}
#[test]
#[serial_test::serial(path_env)]
fn provision_succeeds_without_openssl_yielding_no_apk_key() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
stub_gpg_ok(&tools);
tools.tool("openssl").exit(1).install();
let _g = tools.activate();
let keys = provision_ephemeral_keys(1735689600)
.expect("provision must succeed even when openssl fails");
assert!(
keys.apk_key_path.is_none(),
"apk_key_path must be None when openssl is unavailable"
);
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_cosign_unavailable() {
let tools = FakeToolDir::new();
tools.tool("cosign").exit(1).install();
stub_gpg_ok(&tools);
let _g = tools.activate();
let err = provision_err(0);
assert!(
err.contains("`cosign` not on PATH or failed to run"),
"{err}"
);
assert!(err.contains("Install cosign"), "{err}");
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_cosign_keygen_fails() {
let tools = FakeToolDir::new();
tools
.tool("cosign")
.script(
"case \"$1\" in\n\
version) exit 0 ;;\n\
*) echo 'keygen exploded' 1>&2; exit 3 ;;\n\
esac",
)
.install();
stub_gpg_ok(&tools);
let _g = tools.activate();
let err = provision_err(0);
assert!(err.contains("cosign generate-key-pair failed"), "{err}");
assert!(err.contains("keygen exploded"), "{err}");
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_cosign_key_file_missing() {
let tools = FakeToolDir::new();
tools.tool("cosign").install();
stub_gpg_ok(&tools);
let _g = tools.activate();
let err = provision_err(0);
assert!(err.contains("read cosign key"), "{err}");
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_gpg_unavailable() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
tools.tool("gpg").exit(1).install();
tools.tool("gpgconf").install();
let _g = tools.activate();
let err = provision_err(0);
assert!(err.contains("`gpg` not on PATH or failed to run"), "{err}");
assert!(err.contains("Install GnuPG"), "{err}");
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_on_out_of_range_sde() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
stub_gpg_ok(&tools);
let _g = tools.activate();
let err = provision_err(i64::MAX);
assert!(err.contains("out of range"), "{err}");
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_gen_key_fails() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
tools
.tool("gpg")
.script(
"if [ \"$1\" = '--version' ]; then exit 0; fi\n\
echo 'agent_genkey failed' 1>&2; exit 2",
)
.install();
tools.tool("gpgconf").install();
let _g = tools.activate();
let err = provision_err(0);
assert!(err.contains("gpg --gen-key failed"), "{err}");
assert!(err.contains("agent_genkey failed"), "{err}");
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_list_secret_keys_fails() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
tools
.tool("gpg")
.script(
"if [ \"$1\" = '--version' ]; then exit 0; fi\n\
if [ \"$2\" = '--gen-key' ]; then exit 0; fi\n\
if [ \"$1\" = '--list-secret-keys' ]; then echo 'keyring locked' 1>&2; exit 2; fi\n\
exit 9",
)
.install();
tools.tool("gpgconf").install();
let _g = tools.activate();
let err = provision_err(0);
assert!(err.contains("gpg --list-secret-keys failed"), "{err}");
assert!(err.contains("keyring locked"), "{err}");
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_fingerprint_unparseable() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
tools
.tool("gpg")
.script(
"if [ \"$1\" = '--version' ]; then exit 0; fi\n\
if [ \"$2\" = '--gen-key' ]; then exit 0; fi\n\
if [ \"$1\" = '--list-secret-keys' ]; then printf 'sec:u:255:22::0:::u::\\n'; exit 0; fi\n\
exit 9",
)
.install();
tools.tool("gpgconf").install();
let _g = tools.activate();
let err = provision_err(0);
assert!(
err.contains("could not parse gpg --list-secret-keys"),
"{err}"
);
}
#[test]
#[serial_test::serial(path_env)]
fn provision_bails_when_export_fails() {
let tools = FakeToolDir::new();
stub_cosign_ok(&tools);
tools
.tool("gpg")
.script(format!(
"if [ \"$1\" = '--version' ]; then exit 0; fi\n\
if [ \"$2\" = '--gen-key' ]; then exit 0; fi\n\
if [ \"$1\" = '--list-secret-keys' ]; then printf 'fpr:::::::::{FAKE_FPR}:\\n'; exit 0; fi\n\
if [ \"$2\" = '--armor' ]; then echo 'export denied' 1>&2; exit 2; fi\n\
exit 9"
))
.install();
tools.tool("gpgconf").install();
let _g = tools.activate();
let err = provision_err(0);
assert!(err.contains("gpg --export-secret-keys failed"), "{err}");
assert!(err.contains("export denied"), "{err}");
}
}