use std::path::{Path, PathBuf};
use eyre::{Context as _, Result, bail};
use packslip::cli::{BinInfo, Version};
use packslip::create::{ArtifactInput, ListRequest, ListedRelease, Request};
use packslip::minisign::{PublicKey, SecretKey, key_id_hex};
use packslip::model::{RELEASES_PREDICATE_TYPE, ReleaseListStatement, Source, Statement};
use packslip::sigstore::{self, Policy, Signer, Trust};
use packslip::verify::Options;
use usage_rs::RunWith;
const BIN: BinInfo = BinInfo {
name: "packslip",
version: env!("CARGO_PKG_VERSION"),
};
const BUNDLE_NAME: &str = "packslip.sigstore.json";
#[derive(usage_rs::Cli)]
#[usage(
name = "packslip",
bin = "packslip",
version,
author = "Jeff Dickey <@jdx>",
arg_required_else_help
)]
struct Cli {
#[usage(subcommand)]
command: Option<Commands>,
}
#[derive(usage_rs::Subcommands)]
#[usage(run_with)]
enum Commands {
Create(Box<Create>),
Keygen(Keygen),
Releases(Box<Releases>),
Schema(Schema),
Show(Show),
Verify(Verify),
Version(Version),
}
#[derive(Debug, usage_rs::Args)]
struct Keygen {
#[usage(short = 'o', long, default = "packslip.key")]
out: PathBuf,
}
impl RunWith<BinInfo> for Keygen {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
use std::io::Write as _;
let pubkey = self.out.with_extension("pub");
if self.out == pubkey {
bail!(
"secret and public key paths both resolve to {}",
self.out.display()
);
}
if self.out.exists() || pubkey.exists() {
bail!(
"{} or {} exists; not overwriting a key",
self.out.display(),
pubkey.display()
);
}
let key = SecretKey::generate();
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options
.open(&self.out)
.wrap_err_with(|| format!("creating {}", self.out.display()))?;
file.write_all(key.to_file().as_bytes())?;
let public_result = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&pubkey)
.and_then(|mut public| public.write_all(key.public_key().to_file().as_bytes()));
if let Err(err) = public_result {
let _ = std::fs::remove_file(&self.out);
return Err(err).wrap_err_with(|| format!("writing {}", pubkey.display()));
}
println!(
"wrote {} and {} (key id {})",
self.out.display(),
pubkey.display(),
key_id_hex(&key.public_key().key_id)
);
Ok(())
}
}
#[derive(Debug, usage_rs::Args)]
struct Schema {
#[usage(long)]
releases: bool,
}
impl RunWith<BinInfo> for Schema {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
let schema = if self.releases {
ReleaseListStatement::schema()
} else {
Statement::schema()
};
println!("{}", serde_json::to_string_pretty(&schema)?);
Ok(())
}
}
#[derive(Debug, usage_rs::Args)]
struct Show {
#[usage(value_hint = usage_rs::ValueHint::FilePath)]
bundle: PathBuf,
#[usage(long)]
raw: bool,
}
impl RunWith<BinInfo> for Show {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
let text = std::fs::read_to_string(&self.bundle)
.wrap_err_with(|| format!("reading {}", self.bundle.display()))?;
let payload = sigstore::peek_statement(&text)?;
if self.raw {
use std::io::Write as _;
std::io::stdout().write_all(&payload)?;
println!();
} else {
let value: serde_json::Value = serde_json::from_slice(&payload)?;
println!("{}", serde_json::to_string_pretty(&value)?);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SignWith {
Oidc,
Key,
}
impl std::str::FromStr for SignWith {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"oidc" | "sigstore-oidc" => Ok(SignWith::Oidc),
"key" | "sigstore-key" => Ok(SignWith::Key),
other => Err(format!("--sign must be oidc or key, got {other:?}")),
}
}
}
fn signer(key: &Option<PathBuf>, sign: Option<SignWith>, no_log: bool) -> Result<Signer> {
let sign_with = sign.unwrap_or(if key.is_some() {
SignWith::Key
} else {
SignWith::Oidc
});
match sign_with {
SignWith::Key => {
let Some(path) = key else {
bail!("--sign key needs --key");
};
let key_text = std::fs::read_to_string(path)
.wrap_err_with(|| format!("reading {}", path.display()))?;
Ok(Signer::Key {
key: SecretKey::parse(&key_text)?,
log: !no_log,
})
}
SignWith::Oidc => {
if key.is_some() {
bail!("--key is for --sign key");
}
if no_log {
bail!("keyless signatures are always logged; --no-log is for --key");
}
Ok(Signer::Oidc(sigstore::ambient_identity()?))
}
}
}
#[derive(Debug, usage_rs::Args)]
struct Create {
#[usage(long)]
project: String,
#[usage(long)]
version: String,
#[usage(required = true)]
artifacts: Vec<String>,
#[usage(short = 'k', long, value_hint = usage_rs::ValueHint::FilePath)]
key: Option<PathBuf>,
#[usage(long)]
sign: Option<SignWith>,
#[usage(long)]
no_log: bool,
#[usage(short = 'o', long, default = ".")]
out: PathBuf,
#[usage(long)]
url_base: Option<String>,
#[usage(long)]
source_repo: Option<String>,
#[usage(long)]
commit: Option<String>,
#[usage(long)]
tag: Option<String>,
#[usage(long)]
published_at: Option<String>,
#[usage(long)]
sbom: Option<String>,
#[usage(long)]
supersedes: Option<String>,
#[usage(long)]
bin: Vec<String>,
#[usage(long)]
provenance: Vec<String>,
}
impl RunWith<BinInfo> for Create {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
if self.source_repo.is_none() && (self.commit.is_some() || self.tag.is_some()) {
bail!("--commit and --tag require --source-repo");
}
let signer = signer(&self.key, self.sign, self.no_log)?;
let parsed: Vec<ArtifactSpec> = self.artifacts.iter().map(|s| parse_spec(s)).collect();
let artifacts: Vec<ArtifactInput<'_>> = parsed
.iter()
.enumerate()
.map(|(i, spec)| ArtifactInput {
path: &spec.path,
os: spec.os.as_deref(),
arch: spec.arch.as_deref(),
libc: spec.libc.as_deref(),
bin: self.bin.clone(),
provenance: self.provenance.get(i).cloned().into_iter().collect(),
})
.collect();
let source = self.source_repo.as_ref().map(|repo| Source {
repo: repo.clone(),
commit: self.commit.clone(),
tag: self.tag.clone(),
});
let created = packslip::create::create(&Request {
project: &self.project,
version: &self.version,
published_at: self.published_at.as_deref(),
source,
artifacts,
url_base: self.url_base.as_deref(),
sbom: self.sbom.as_deref(),
supersedes: self.supersedes.as_deref(),
identity: signer.identity(),
})?;
let identity = created.statement.predicate.identity.clone();
let bundle = sigstore::sign(signer, &created.document)?;
std::fs::create_dir_all(&self.out)
.wrap_err_with(|| format!("creating {}", self.out.display()))?;
let path = self.out.join(BUNDLE_NAME);
std::fs::write(&path, &bundle)?;
println!(
"wrote {} ({} artifact(s), signed by {}{})",
path.display(),
created.statement.predicate.artifacts.len(),
identity.key_id,
if self.no_log { ", unlogged" } else { "" }
);
Ok(())
}
}
struct ArtifactSpec {
path: PathBuf,
os: Option<String>,
arch: Option<String>,
libc: Option<String>,
}
fn parse_spec(spec: &str) -> ArtifactSpec {
match spec.rsplit_once(':') {
Some((path, platform)) if valid_platform(platform) => {
let mut parts = platform.split('/');
ArtifactSpec {
path: PathBuf::from(path),
os: parts.next().map(str::to_string),
arch: parts.next().map(str::to_string),
libc: parts.next().map(str::to_string),
}
}
_ => ArtifactSpec {
path: PathBuf::from(spec),
os: None,
arch: None,
libc: None,
},
}
}
fn valid_platform(platform: &str) -> bool {
let parts: Vec<_> = platform.split('/').collect();
(parts.len() == 2 || parts.len() == 3)
&& parts.iter().all(|part| {
part.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
&& part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
})
}
#[derive(Debug, usage_rs::Args)]
struct Releases {
#[usage(long)]
project: String,
#[usage(long)]
sequence: u64,
#[usage(long, default = "30d")]
valid_for: String,
#[usage(long)]
generated_at: Option<String>,
#[usage(long, required = true)]
release: Vec<String>,
#[usage(short = 'k', long, value_hint = usage_rs::ValueHint::FilePath)]
key: Option<PathBuf>,
#[usage(long)]
sign: Option<SignWith>,
#[usage(long)]
no_log: bool,
#[usage(short = 'o', long, default = "packslip-releases.sigstore.json")]
out: PathBuf,
}
impl RunWith<BinInfo> for Releases {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
let signer = signer(&self.key, self.sign, self.no_log)?;
let mut pairs = Vec::new();
for spec in &self.release {
let Some((url, path)) = spec.split_once('=') else {
bail!("--release wants URL=PATH, got {spec:?}");
};
pairs.push((url.to_string(), PathBuf::from(path)));
}
let releases = pairs
.iter()
.map(|(url, path)| ListedRelease {
url,
bundle_path: path,
})
.collect();
let created = packslip::create::create_release_list(&ListRequest {
project: &self.project,
generated_at: self.generated_at.as_deref(),
valid_for: parse_duration(&self.valid_for)?,
sequence: self.sequence,
releases,
identity: signer.identity(),
})?;
let bundle = sigstore::sign(signer, &created.document)?;
if let Some(parent) = self.out.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.out, &bundle)?;
println!(
"wrote {} ({} release(s), sequence {}, expires {})",
self.out.display(),
created.statement.predicate.releases.len(),
created.statement.predicate.sequence,
created.statement.predicate.expires_at
);
Ok(())
}
}
fn parse_duration(s: &str) -> Result<std::time::Duration> {
let s = s.trim();
let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
let (number, unit) = s.split_at(split);
let number: u64 = number.parse().wrap_err_with(|| format!("duration {s:?}"))?;
let seconds = match unit {
"s" => 1,
"m" => 60,
"h" => 3600,
"d" => 86_400,
"w" => 7 * 86_400,
_ => bail!("duration {s:?}: expected a unit of s, m, h, d or w"),
};
Ok(std::time::Duration::from_secs(number * seconds))
}
#[derive(Debug, usage_rs::Args)]
struct Verify {
#[usage(value_hint = usage_rs::ValueHint::FilePath)]
bundle: PathBuf,
#[usage(short = 'p', long)]
pubkey: Option<String>,
#[usage(long)]
identity: Option<String>,
#[usage(long)]
identity_prefix: Option<String>,
#[usage(long)]
issuer: Option<String>,
#[usage(long)]
allow_unlogged: bool,
#[usage(long, value_hint = usage_rs::ValueHint::FilePath)]
trusted_root: Option<PathBuf>,
#[usage(short = 'a', long)]
artifact: Vec<PathBuf>,
#[usage(short = 'J', long)]
json: bool,
}
enum Pin {
Key(PublicKey),
Identity(Policy),
}
impl Pin {
fn as_trust(&self) -> Trust<'_> {
match self {
Pin::Key(key) => Trust::Key(key),
Pin::Identity(policy) => Trust::Identity(policy),
}
}
}
impl RunWith<BinInfo> for Verify {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
let text = std::fs::read_to_string(&self.bundle)
.wrap_err_with(|| format!("reading {}", self.bundle.display()))?;
let peeked: serde_json::Value = serde_json::from_slice(&sigstore::peek_statement(&text)?)
.wrap_err("the bundle's payload is not JSON")?;
let project = peeked["predicate"]["project"]
.as_str()
.unwrap_or_default()
.to_string();
let is_list = peeked["predicateType"] == RELEASES_PREDICATE_TYPE;
let pin = match &self.pubkey {
Some(text) => {
if self.identity.is_some()
|| self.identity_prefix.is_some()
|| self.issuer.is_some()
{
bail!(
"--pubkey pins a key; the identity flags pin a certificate. Pass one kind"
);
}
let pubkey_text = if Path::new(text).is_file() {
std::fs::read_to_string(text)?
} else {
text.clone()
};
Pin::Key(PublicKey::parse(&pubkey_text)?)
}
None => {
let explicit = Policy {
issuer: self.issuer.clone(),
identity: self.identity.clone(),
identity_prefix: self.identity_prefix.clone(),
};
Pin::Identity(if explicit.is_empty() {
Policy::for_project(&project).ok_or(sigstore::Error::NoPolicy(project))?
} else {
explicit
})
}
};
let root_json = match &self.trusted_root {
Some(path) => Some(
std::fs::read_to_string(path)
.wrap_err_with(|| format!("reading {}", path.display()))?,
),
None => None,
};
let trusted_root = sigstore::trusted_root(root_json.as_deref())?;
let options = Options {
require_log: !self.allow_unlogged,
trusted_root: &trusted_root,
};
let artifacts: Vec<&Path> = self.artifact.iter().map(PathBuf::as_path).collect();
if is_list {
if !artifacts.is_empty() {
bail!("a release list has no artifacts to check");
}
return match packslip::verify_release_list(&text, &pin.as_trust(), options) {
Ok(verified) => {
let list = &verified.list.predicate;
if self.json {
println!("{}", serde_json::to_string_pretty(&verified.list)?);
} else {
println!(
"ok: release list for {} sequence {} expires {} signed by {} ({}) listing {} release(s)",
list.project,
list.sequence,
list.expires_at,
verified.key_id,
verified.scheme,
list.releases.len()
);
}
Ok(())
}
Err(err) => {
eprintln!("verification failed: {err}");
std::process::exit(1)
}
};
}
match packslip::verify(&text, &pin.as_trust(), options, &artifacts) {
Ok(verified) => {
if self.json {
println!("{}", serde_json::to_string_pretty(&verified)?);
} else {
println!(
"ok: {} {} published {} signed by {} ({}){} ({} of {} artifact(s) checked{})",
verified.project,
verified.version,
verified.published_at,
verified.key_id,
verified.scheme,
match &verified.logged_at {
Some(at) => format!(" logged {at}"),
None => " unlogged".to_string(),
},
verified.checked_artifacts.len(),
verified.artifact_count,
if verified.provenance_linked {
", provenance linked"
} else {
""
}
);
}
Ok(())
}
Err(err) => {
eprintln!("verification failed: {err}");
std::process::exit(1)
}
}
}
}
fn main() -> Result<()> {
color_eyre::install()?;
let args: Vec<_> = std::env::args_os().collect();
let argv = packslip::cli::argv(&args);
let cli = packslip::cli::unwrap_or_exit(Cli::spec(), &argv, Cli::parse_from_argv(&argv));
match cli.command {
Some(command) => command.run_with(BIN),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artifact_platform_suffix_does_not_consume_timestamped_paths() {
let spec = parse_spec("build/2026-09-01T12:00:00Z/tool.tar.gz");
assert_eq!(
spec.path,
PathBuf::from("build/2026-09-01T12:00:00Z/tool.tar.gz")
);
assert!(spec.os.is_none());
let spec = parse_spec("weird.bin:freebsd/riscv64");
assert_eq!(spec.os.as_deref(), Some("freebsd"));
assert_eq!(spec.arch.as_deref(), Some("riscv64"));
let spec = parse_spec("tool.tar.gz:linux/x86_64/musl");
assert_eq!(spec.libc.as_deref(), Some("musl"));
}
#[test]
fn durations() {
assert_eq!(
parse_duration("30d").unwrap(),
std::time::Duration::from_secs(30 * 86_400)
);
assert_eq!(
parse_duration("2w").unwrap(),
std::time::Duration::from_secs(14 * 86_400)
);
assert!(parse_duration("3x").is_err());
assert!(parse_duration("").is_err());
}
}