use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeTarballManifest {
pub name: String,
pub version: String,
pub triple: String,
pub binary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
pub fn pack_native_tarball(
binary_path: &Path,
manifest: &NativeTarballManifest,
output_path: &Path,
) -> std::io::Result<()> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let manifest_toml = toml::to_string_pretty(manifest)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let file = fs::File::create(output_path)?;
let gz = GzEncoder::new(file, Compression::default());
let mut builder = tar::Builder::new(gz);
let bin_basename = binary_path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("binary path has no filename: {}", binary_path.display()),
)
})?;
let in_tar_path = format!("bin/{}", bin_basename.to_string_lossy());
let mut bin = fs::File::open(binary_path)?;
let bin_meta = bin.metadata()?;
let mut bin_header = tar::Header::new_gnu();
bin_header.set_size(bin_meta.len());
bin_header.set_mode(0o755);
bin_header.set_mtime(0);
bin_header.set_cksum();
builder.append_data(&mut bin_header, &in_tar_path, &mut bin)?;
let manifest_bytes = manifest_toml.as_bytes();
let mut manifest_header = tar::Header::new_gnu();
manifest_header.set_size(manifest_bytes.len() as u64);
manifest_header.set_mode(0o644);
manifest_header.set_mtime(0);
manifest_header.set_cksum();
builder.append_data(&mut manifest_header, "manifest.toml", manifest_bytes)?;
let gz = builder.into_inner()?;
gz.finish()?;
Ok(())
}
pub fn tarball_stem(image_name: &str, triple: &str) -> String {
let raw = format!("{image_name}-{triple}");
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
c
} else {
'_'
}
})
.collect()
}
pub fn native_tarball_output_path(camp_root: &Path, image_name: &str, triple: &str) -> PathBuf {
camp_root
.join(".yah/cache/native")
.join(format!("{}.tar.gz", tarball_stem(image_name, triple)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedBlob {
pub signature_path: PathBuf,
pub certificate_path: Option<PathBuf>,
pub bundle_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SigningIdentity {
Keyless {
identity_token: Option<String>,
},
Key { key_ref: String },
}
impl Default for SigningIdentity {
fn default() -> Self {
Self::Keyless {
identity_token: None,
}
}
}
pub const ENV_COSIGN_KEY: &str = "QED_COSIGN_KEY";
pub const ENV_COSIGN_IDENTITY_TOKEN: &str = "QED_COSIGN_IDENTITY_TOKEN";
impl SigningIdentity {
pub fn from_env() -> Option<Self> {
Self::from_env_with(|k| std::env::var(k).ok())
}
pub fn from_env_with(lookup: impl Fn(&str) -> Option<String>) -> Option<Self> {
let non_empty = |k: &str| lookup(k).filter(|v| !v.trim().is_empty());
if let Some(key_ref) = non_empty(ENV_COSIGN_KEY) {
return Some(Self::Key { key_ref });
}
non_empty(ENV_COSIGN_IDENTITY_TOKEN).map(|t| Self::Keyless {
identity_token: Some(t),
})
}
}
#[async_trait]
pub trait SigstoreSigner: Send + Sync {
async fn sign_blob(&self, blob_path: &Path) -> std::io::Result<SignedBlob>;
}
fn append_suffix(blob: &Path, suffix: &str) -> PathBuf {
let mut s = blob.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
pub struct CosignSigner {
pub cosign_bin: PathBuf,
pub identity: SigningIdentity,
}
impl Default for CosignSigner {
fn default() -> Self {
Self {
cosign_bin: PathBuf::from("cosign"),
identity: SigningIdentity::default(),
}
}
}
impl CosignSigner {
pub fn keyless() -> Self {
Self::default()
}
pub fn with_key(key_ref: impl Into<String>) -> Self {
Self {
identity: SigningIdentity::Key {
key_ref: key_ref.into(),
},
..Self::default()
}
}
pub fn with_bin(mut self, cosign_bin: impl Into<PathBuf>) -> Self {
self.cosign_bin = cosign_bin.into();
self
}
fn emits_certificate(&self) -> bool {
matches!(self.identity, SigningIdentity::Keyless { .. })
}
fn sign_blob_argv(
&self,
blob_path: &Path,
sig: &Path,
crt: &Path,
bundle: &Path,
) -> Vec<OsString> {
let mut argv: Vec<OsString> = vec!["sign-blob".into(), "--yes".into()];
match &self.identity {
SigningIdentity::Keyless { identity_token } => {
if let Some(token) = identity_token {
argv.push("--identity-token".into());
argv.push(token.into());
}
argv.push("--output-certificate".into());
argv.push(crt.into());
}
SigningIdentity::Key { key_ref } => {
argv.push("--key".into());
argv.push(key_ref.into());
argv.push("--tlog-upload=false".into());
}
}
argv.push("--output-signature".into());
argv.push(sig.into());
argv.push("--bundle".into());
argv.push(bundle.into());
argv.push(blob_path.into());
argv
}
}
#[async_trait]
impl SigstoreSigner for CosignSigner {
async fn sign_blob(&self, blob_path: &Path) -> std::io::Result<SignedBlob> {
let sig = append_suffix(blob_path, ".sig");
let crt = append_suffix(blob_path, ".crt");
let bundle = append_suffix(blob_path, ".bundle");
let status = tokio::process::Command::new(&self.cosign_bin)
.args(self.sign_blob_argv(blob_path, &sig, &crt, &bundle))
.status()
.await?;
if !status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!(
"cosign sign-blob exited with status {} (blob: {})",
status,
blob_path.display(),
),
));
}
Ok(SignedBlob {
signature_path: sig,
certificate_path: self.emits_certificate().then_some(crt),
bundle_path: Some(bundle),
})
}
}
pub fn resolve_signer() -> Arc<dyn SigstoreSigner> {
match SigningIdentity::from_env() {
Some(identity) => {
tracing::info!(
identity = match &identity {
SigningIdentity::Key { .. } => "key",
SigningIdentity::Keyless { .. } => "keyless",
},
"qed: signing with cosign"
);
Arc::new(CosignSigner {
identity,
..CosignSigner::default()
})
}
None => {
tracing::debug!(
"qed: no signing identity configured ({ENV_COSIGN_KEY} / \
{ENV_COSIGN_IDENTITY_TOKEN} unset) — using LoggingSigner placeholders"
);
Arc::new(LoggingSigner)
}
}
}
pub struct LoggingSigner;
#[async_trait]
impl SigstoreSigner for LoggingSigner {
async fn sign_blob(&self, blob_path: &Path) -> std::io::Result<SignedBlob> {
if !blob_path.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("blob to sign not found: {}", blob_path.display()),
));
}
let sig = append_suffix(blob_path, ".sig");
let crt = append_suffix(blob_path, ".crt");
let bundle = append_suffix(blob_path, ".bundle");
fs::write(
&sig,
b"# yah logging-signer: placeholder signature (NOT a real cosign signature)\n",
)?;
fs::write(
&crt,
b"# yah logging-signer: placeholder certificate (NOT a real cosign cert)\n",
)?;
fs::write(
&bundle,
b"{\"_comment\":\"yah logging-signer placeholder bundle\"}\n",
)?;
tracing::warn!(
blob = %blob_path.display(),
"qed sign-native-tarball: LoggingSigner emitted placeholder \
.sig/.crt/.bundle (cosign not wired)"
);
Ok(SignedBlob {
signature_path: sig,
certificate_path: Some(crt),
bundle_path: Some(bundle),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::GzDecoder;
use std::io::{Read, Write};
use tempfile::TempDir;
fn write_dummy_binary(dir: &Path, name: &str, body: &[u8]) -> std::path::PathBuf {
let path = dir.join(name);
let mut f = fs::File::create(&path).unwrap();
f.write_all(body).unwrap();
path
}
fn sample_manifest() -> NativeTarballManifest {
NativeTarballManifest {
name: "yah-yubaba".into(),
version: "0.8.6".into(),
triple: "x86_64-unknown-linux-musl".into(),
binary: "bin/yubaba".into(),
description: Some("Native musl-static yubaba".into()),
env: BTreeMap::from([("RUST_LOG".into(), "info".into())]),
}
}
fn list_tar_entries(path: &Path) -> Vec<(String, Vec<u8>, u32)> {
let f = fs::File::open(path).unwrap();
let gz = GzDecoder::new(f);
let mut archive = tar::Archive::new(gz);
let mut out = Vec::new();
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
let header_path = entry.path().unwrap().to_string_lossy().into_owned();
let mode = entry.header().mode().unwrap();
let mut buf = Vec::new();
entry.read_to_end(&mut buf).unwrap();
out.push((header_path, buf, mode));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
#[test]
fn pack_writes_binary_and_manifest_with_expected_modes() {
let dir = TempDir::new().unwrap();
let bin = write_dummy_binary(dir.path(), "yubaba", b"\x7fELF-fake-musl-binary");
let out = dir
.path()
.join("out/yah-yubaba-x86_64-unknown-linux-musl.tar.gz");
let manifest = sample_manifest();
pack_native_tarball(&bin, &manifest, &out).unwrap();
assert!(out.is_file(), "tarball materialised at {}", out.display());
let entries = list_tar_entries(&out);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].0, "bin/yubaba");
assert_eq!(entries[0].1, b"\x7fELF-fake-musl-binary");
assert_eq!(entries[0].2, 0o755);
assert_eq!(entries[1].0, "manifest.toml");
assert_eq!(entries[1].2, 0o644);
}
#[test]
fn pack_manifest_roundtrips_through_toml() {
let dir = TempDir::new().unwrap();
let bin = write_dummy_binary(dir.path(), "yubaba", b"x");
let out = dir.path().join("yubaba.tar.gz");
let manifest = sample_manifest();
pack_native_tarball(&bin, &manifest, &out).unwrap();
let entries = list_tar_entries(&out);
let manifest_entry = entries
.iter()
.find(|(p, _, _)| p == "manifest.toml")
.expect("manifest.toml present");
let text = std::str::from_utf8(&manifest_entry.1).unwrap();
let parsed: NativeTarballManifest = toml::from_str(text).expect("manifest.toml parses");
assert_eq!(parsed, manifest);
}
#[test]
fn pack_creates_missing_parent_dirs() {
let dir = TempDir::new().unwrap();
let bin = write_dummy_binary(dir.path(), "yubaba", b"x");
let out = dir.path().join("deeply/nested/path/yubaba.tar.gz");
pack_native_tarball(&bin, &sample_manifest(), &out).unwrap();
assert!(out.is_file());
}
#[test]
fn pack_missing_binary_is_io_error() {
let dir = TempDir::new().unwrap();
let bogus = dir.path().join("does-not-exist");
let out = dir.path().join("out.tar.gz");
let err = pack_native_tarball(&bogus, &sample_manifest(), &out).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn tarball_stem_replaces_unsafe_chars() {
assert_eq!(
tarball_stem("yah-yubaba", "x86_64-unknown-linux-musl"),
"yah-yubaba-x86_64-unknown-linux-musl",
);
assert_eq!(
tarball_stem("ghcr.io/yah-ai/yah-yubaba", "linux:musl"),
"ghcr.io_yah-ai_yah-yubaba-linux_musl",
);
}
#[test]
fn native_tarball_output_path_matches_runner_convention() {
let camp = Path::new("/camp");
let out = native_tarball_output_path(camp, "yah-yubaba", "x86_64-unknown-linux-musl");
assert_eq!(
out,
Path::new("/camp/.yah/cache/native/yah-yubaba-x86_64-unknown-linux-musl.tar.gz"),
);
}
#[tokio::test]
async fn logging_signer_writes_placeholder_sig_crt_bundle_next_to_blob() {
let dir = TempDir::new().unwrap();
let blob = dir
.path()
.join("yah-yubaba-x86_64-unknown-linux-musl.tar.gz");
fs::write(&blob, b"<fake tarball bytes>").unwrap();
let signer = LoggingSigner;
let signed = signer.sign_blob(&blob).await.unwrap();
assert_eq!(
signed.signature_path,
blob.with_file_name(format!(
"{}.sig",
blob.file_name().unwrap().to_string_lossy()
)),
);
let cert = signed
.certificate_path
.clone()
.expect("LoggingSigner mirrors the keyless shape, cert included");
assert_eq!(
cert,
blob.with_file_name(format!(
"{}.crt",
blob.file_name().unwrap().to_string_lossy()
)),
);
let bundle = signed.bundle_path.expect("LoggingSigner emits a bundle");
assert_eq!(
bundle,
blob.with_file_name(format!(
"{}.bundle",
blob.file_name().unwrap().to_string_lossy()
)),
);
assert!(fs::read(&signed.signature_path).unwrap().len() > 10);
assert!(fs::read(&cert).unwrap().len() > 10);
assert!(fs::read(&bundle).unwrap().len() > 10);
}
#[tokio::test]
async fn logging_signer_missing_blob_is_not_found_error() {
let dir = TempDir::new().unwrap();
let bogus = dir.path().join("does-not-exist.tar.gz");
let err = LoggingSigner.sign_blob(&bogus).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[tokio::test]
async fn cosign_signer_missing_binary_surfaces_not_found() {
let dir = TempDir::new().unwrap();
let blob = dir.path().join("artifact.tar.gz");
fs::write(&blob, b"x").unwrap();
let signer = CosignSigner::keyless().with_bin("/definitely/not/a/real/cosign-binary");
let err = signer.sign_blob(&blob).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
fn argv_of(signer: &CosignSigner) -> Vec<String> {
signer
.sign_blob_argv(
Path::new("/a/x.tar.gz"),
Path::new("/a/x.tar.gz.sig"),
Path::new("/a/x.tar.gz.crt"),
Path::new("/a/x.tar.gz.bundle"),
)
.into_iter()
.map(|s| s.to_string_lossy().into_owned())
.collect()
}
#[test]
fn keyless_argv_is_unchanged_from_the_gha_shape() {
assert_eq!(
argv_of(&CosignSigner::keyless()),
vec![
"sign-blob",
"--yes",
"--output-certificate",
"/a/x.tar.gz.crt",
"--output-signature",
"/a/x.tar.gz.sig",
"--bundle",
"/a/x.tar.gz.bundle",
"/a/x.tar.gz",
],
);
}
#[test]
fn keyless_with_token_passes_identity_token() {
let signer = CosignSigner {
identity: SigningIdentity::Keyless {
identity_token: Some("eyJhbGc.camp-minted".into()),
},
..CosignSigner::default()
};
let argv = argv_of(&signer);
let i = argv.iter().position(|a| a == "--identity-token").unwrap();
assert_eq!(argv[i + 1], "eyJhbGc.camp-minted");
}
#[test]
fn key_argv_uses_key_and_emits_no_certificate() {
let argv = argv_of(&CosignSigner::with_key("awskms:///alias/yah-release"));
assert_eq!(
argv,
vec![
"sign-blob",
"--yes",
"--key",
"awskms:///alias/yah-release",
"--tlog-upload=false",
"--output-signature",
"/a/x.tar.gz.sig",
"--bundle",
"/a/x.tar.gz.bundle",
"/a/x.tar.gz",
],
);
assert!(!argv.iter().any(|a| a == "--output-certificate"));
}
#[test]
fn key_argv_disables_tlog_upload_but_keyless_does_not() {
assert!(argv_of(&CosignSigner::with_key("/vaulted/cosign.key"))
.iter()
.any(|a| a == "--tlog-upload=false"));
assert!(!argv_of(&CosignSigner::keyless())
.iter()
.any(|a| a == "--tlog-upload=false"));
}
#[tokio::test]
async fn key_signing_reports_no_certificate_path() {
assert!(!CosignSigner::with_key("cosign.key").emits_certificate());
assert!(CosignSigner::keyless().emits_certificate());
}
#[test]
fn from_env_prefers_key_over_ambient_token() {
let both = SigningIdentity::from_env_with(|k| match k {
ENV_COSIGN_KEY => Some("cosign.key".into()),
ENV_COSIGN_IDENTITY_TOKEN => Some("tok".into()),
_ => None,
});
assert_eq!(
both,
Some(SigningIdentity::Key {
key_ref: "cosign.key".into()
})
);
}
#[test]
fn from_env_is_none_when_unset_or_blank() {
assert_eq!(SigningIdentity::from_env_with(|_| None), None);
assert_eq!(
SigningIdentity::from_env_with(|_| Some(" ".into())),
None
);
}
#[test]
fn from_env_token_only_is_keyless() {
assert_eq!(
SigningIdentity::from_env_with(|k| (k == ENV_COSIGN_IDENTITY_TOKEN)
.then(|| "tok".to_string())),
Some(SigningIdentity::Keyless {
identity_token: Some("tok".into())
})
);
}
}