use std::path::{Path, PathBuf};
use eyre::{Context as _, Result, bail};
use packslip::cli::{BinInfo, Version};
#[cfg(all(feature = "create", feature = "manifest"))]
use packslip::create::{ArtifactInput, AssetInput, ListRequest, ListedRelease, Request};
use packslip::fingerprint::Fingerprint;
#[cfg(all(feature = "create", feature = "manifest"))]
use packslip::manifest::Manifest;
use packslip::minisign::PublicKey;
#[cfg(feature = "sign")]
use packslip::minisign::{SecretKey, key_id_hex};
use packslip::model::{Attestor, RELEASES_PREDICATE_TYPE};
#[cfg(all(feature = "create", feature = "manifest"))]
use packslip::model::{Bin, Evidence, Extensions, RequiredBin, Resource, Source};
#[cfg(feature = "schema")]
use packslip::model::{ReleaseListStatement, Statement};
#[cfg(all(feature = "create", feature = "manifest"))]
use packslip::sigstore::Signer;
use packslip::sigstore::{self, Policy, Trust};
use packslip::verify::Options;
use usage_rs::RunWith;
const BIN: BinInfo = BinInfo {
name: "packslip",
version: env!("CARGO_PKG_VERSION"),
};
#[cfg(all(feature = "create", feature = "manifest"))]
pub fn bundle_name(project: &str) -> String {
match packslip::model::repository_subpath(project) {
Some(sub) => format!("packslip.{}.sigstore.json", sub.replace('/', "-")),
None => "packslip.sigstore.json".to_string(),
}
}
#[derive(usage_rs::Cli)]
#[usage(completion = true)]
#[usage(
name = "packslip",
bin = "packslip",
version,
author = "Jeff Dickey <@jdx>, Shunsuke Suzuki <@suzuki-shunsuke>",
arg_required_else_help
)]
struct Cli {
#[usage(subcommand)]
command: Option<Commands>,
}
#[derive(usage_rs::Subcommands)]
#[usage(run_with)]
enum Commands {
Completion(Completion),
#[cfg(all(feature = "create", feature = "manifest"))]
Create(Box<Create>),
#[cfg(feature = "install-cli")]
Install(Box<Install>),
#[cfg(feature = "sign")]
Keygen(Keygen),
Pin(Box<Pin>),
#[cfg(all(feature = "create", feature = "manifest"))]
Releases(Box<Releases>),
#[cfg(feature = "schema")]
Schema(Schema),
Show(Show),
#[usage(hide)]
Usage(Usage),
Verify(Verify),
Version(Version),
}
#[cfg(feature = "install-cli")]
#[derive(Debug, usage_rs::Args)]
#[usage(
example(
"packslip install github.com/jdx/hk",
header = "Install the latest release of hk"
),
example(
"packslip install github.com/jdx/hk \\\n --version 2.8.0 \\\n --pin ps1_snirenkjwr7m5ozgcufameodnm",
header = "Install hk 2.8.0 and require its signer fingerprint"
),
example(
"packslip install mytool.example.com --pubkey release.pub",
header = "Install a key-signed domain project"
)
)]
struct Install {
#[usage(arg)]
project: String,
#[usage(long)]
version: Option<String>,
#[usage(long)]
system: bool,
#[usage(long)]
user: bool,
#[usage(long)]
force: bool,
#[usage(long)]
offline: bool,
#[usage(long, value_hint = usage_rs::ValueHint::DirPath)]
install_dir: Option<PathBuf>,
#[usage(long, value_hint = usage_rs::ValueHint::DirPath)]
bin_dir: Option<PathBuf>,
#[usage(long)]
variant: Option<String>,
#[usage(long)]
pin: Vec<String>,
#[usage(long)]
pubkey: Option<String>,
#[usage(long)]
issuer: Option<String>,
#[usage(long)]
identity: Option<String>,
#[usage(long)]
identity_prefix: Option<String>,
#[usage(long)]
accept_trust_change: Vec<String>,
#[usage(long, value_hint = usage_rs::ValueHint::FilePath)]
trusted_root: Option<PathBuf>,
#[usage(long)]
allow_unlogged: bool,
#[usage(long)]
allow_incompatible_host: bool,
#[usage(long, default = "10737418240")]
max_extracted_size: u64,
#[usage(long, default = "100000")]
max_archive_entries: u64,
}
#[cfg(feature = "install-cli")]
impl RunWith<BinInfo> for Install {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Result<()> {
let request = packslip::install::Request {
project: self.project,
version: self.version,
system: self.system,
user: self.user,
force: self.force,
offline: self.offline,
install_dir: self.install_dir,
bin_dir: self.bin_dir,
variant: self.variant,
constraints: packslip::install_policy::Constraints {
pins: self.pin,
pubkey: self.pubkey,
issuer: self.issuer,
identity: self.identity,
identity_prefix: self.identity_prefix,
},
accept_trust_change: self.accept_trust_change,
trusted_root: self.trusted_root,
allow_unlogged: self.allow_unlogged,
allow_incompatible_host: self.allow_incompatible_host,
limits: packslip::install_extract::Limits {
bytes: self.max_extracted_size,
entries: self.max_archive_entries,
},
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let report = runtime.block_on(packslip::install::run(request))?;
print_install_report(
report,
&mut std::io::stdout().lock(),
&mut std::io::stderr().lock(),
)?;
Ok(())
}
}
#[cfg(feature = "install-cli")]
fn print_install_report(
report: packslip::install::Report,
out: &mut impl std::io::Write,
err: &mut impl std::io::Write,
) -> std::io::Result<()> {
writeln!(
out,
"Installed {} {} at {}",
report.project,
report.receipt.version,
report.receipt.tree.display()
)?;
if let Some(fingerprint) = report.pin {
writeln!(out, "Signer fingerprint: {fingerprint}")?;
}
for warning in report.warnings {
writeln!(err, "warning: {warning}")?;
}
for path in report.receipt.exports.keys() {
writeln!(out, "Command: {}", path.display())?;
}
Ok(())
}
#[derive(Debug, usage_rs::Args)]
#[usage(example(
"mkdir -p ~/.local/share/bash-completion/completions && packslip completion bash > ~/.local/share/bash-completion/completions/packslip",
header = "Install bash completions for your user"
))]
struct Completion {
#[usage(
arg,
choices("bash", "elvish", "zsh", "fish", "nu", "nushell", "powershell", "pwsh")
)]
shell: String,
}
impl RunWith<BinInfo> for Completion {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
let shell = usage_rs::complete::Shell::from_name(&self.shell)
.ok_or_else(|| eyre::eyre!("unsupported shell: {}", self.shell))?;
print!("{}", Cli::completion_script(shell));
Ok(())
}
}
#[derive(Debug, usage_rs::Args)]
struct Usage;
impl RunWith<BinInfo> for Usage {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
println!(
"// @generated by `packslip usage`\n{}",
Cli::to_kdl().trim_end()
);
Ok(())
}
}
#[derive(Debug, usage_rs::Args)]
#[usage(example(
"packslip keygen --out release.key",
header = "Write release.key and release.pub"
))]
#[cfg(feature = "sign")]
struct Keygen {
#[usage(short = 'o', long, default = "packslip.key")]
out: PathBuf,
}
#[cfg(feature = "sign")]
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)]
#[cfg(feature = "schema")]
struct Schema {
#[usage(long)]
releases: bool,
#[usage(long)]
report: bool,
}
#[cfg(feature = "schema")]
impl RunWith<BinInfo> for Schema {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
let schema = if self.report {
packslip::verify_report_schema()
} else if self.releases {
ReleaseListStatement::schema()
} else {
Statement::schema()
};
println!("{}", serde_json::to_string_pretty(&schema)?);
Ok(())
}
}
#[derive(Debug, usage_rs::Args)]
#[usage(example(
"packslip show packslip.sigstore.json",
header = "Read the statement of a release bundle"
))]
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)]
#[cfg(all(feature = "create", feature = "manifest"))]
enum SignWith {
Oidc,
Key,
}
#[cfg(all(feature = "create", feature = "manifest"))]
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:?}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(all(feature = "create", feature = "manifest"))]
struct AttestorArg(Attestor);
#[cfg(all(feature = "create", feature = "manifest"))]
impl std::str::FromStr for AttestorArg {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"vendor" => Ok(AttestorArg(Attestor::Vendor)),
"repackager" => Ok(AttestorArg(Attestor::Repackager)),
other => Err(format!(
"--attested-by must be vendor or repackager, got {other:?}"
)),
}
}
}
#[cfg(all(feature = "create", feature = "manifest"))]
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)]
#[usage(
example(
"packslip create \\\n --project github.com/owner/mytool \\\n --version 1.2.3 \\\n --bin mytool \\\n --url-base https://github.com/owner/mytool/releases/download/v1.2.3 \\\n --out dist \\\n dist/mytool-1.2.3-linux-x64.tar.gz \\\n dist/mytool-1.2.3-darwin-arm64.tar.gz",
header = "Sign keyless in a CI job"
),
example(
"packslip create --manifest release.toml --out dist",
header = "Describe the artifacts in release.toml"
),
example(
"packslip create \\\n --project mytool.example.com \\\n --version 1.2.3 \\\n --bin mytool \\\n --url-base https://mytool.example.com/v1.2.3 \\\n --key release.key \\\n --out dist \\\n dist/mytool-1.2.3-linux-x64.tar.gz",
header = "Sign with a key from packslip keygen"
)
)]
#[cfg(all(feature = "create", feature = "manifest"))]
struct Create {
#[usage(long, help_heading = "Input")]
project: Option<String>,
#[usage(long, help_heading = "Input")]
version: Option<String>,
artifacts: Vec<String>,
#[usage(short = 'm', long, value_hint = usage_rs::ValueHint::FilePath, help_heading = "Input")]
manifest: Option<PathBuf>,
#[usage(short = 'k', long, value_hint = usage_rs::ValueHint::FilePath, help_heading = "Signing")]
key: Option<PathBuf>,
#[usage(long, help_heading = "Signing")]
sign: Option<SignWith>,
#[usage(long, help_heading = "Signing")]
no_log: bool,
#[usage(short = 'o', long, default = ".", help_heading = "Output")]
out: PathBuf,
#[usage(long, help_heading = "Download URLs")]
url_base: Option<String>,
#[usage(long, help_heading = "Download URLs")]
url: Vec<String>,
#[usage(long, help_heading = "Artifacts")]
format: Vec<String>,
#[usage(long, help_heading = "Source")]
source_repo: Option<String>,
#[usage(long, help_heading = "Source")]
commit: Option<String>,
#[usage(long, help_heading = "Source")]
tag: Option<String>,
#[usage(long, help_heading = "Release metadata")]
published_at: Option<String>,
#[usage(long, help_heading = "Release metadata")]
notes_url: Option<String>,
#[usage(long, help_heading = "Release metadata")]
extension: Vec<String>,
#[usage(long, help_heading = "Artifacts")]
bin: Vec<String>,
#[usage(long, help_heading = "Resources")]
resource: Vec<String>,
#[usage(long, help_heading = "Artifacts")]
provenance: Vec<String>,
#[usage(long, help_heading = "Repackagers")]
attested_by: Option<AttestorArg>,
#[usage(long, help_heading = "Repackagers")]
evidence: Vec<String>,
#[usage(long, help_heading = "Artifacts")]
no_sha512: bool,
#[usage(long, help_heading = "Artifacts")]
require: Vec<String>,
#[usage(long, help_heading = "Artifacts")]
no_libs: bool,
#[usage(long, help_heading = "Signing")]
no_pin_workflow: bool,
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn declared_identity(signer: &Signer, no_pin_workflow: bool) -> Result<packslip::model::Identity> {
let mut identity = signer.identity();
if no_pin_workflow {
if identity.scheme != packslip::model::Scheme::SigstoreOidc {
bail!("--no-pin-workflow applies to keyless signing; a key has no workflow");
}
identity.pin_workflow = Some(false);
}
Ok(identity)
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn parse_bin(spec: &str) -> Bin {
match spec.split_once('=') {
Some((name, path)) if !name.is_empty() && !path.is_empty() => Bin::named(path, name),
_ => Bin::new(spec),
}
}
#[cfg(all(feature = "create", feature = "manifest"))]
struct ResourceSpec {
resource: Resource,
asset_path: Option<PathBuf>,
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn parse_resource(spec: &str, default_bin: Option<&str>) -> Result<ResourceSpec> {
let Some((head, value)) = spec.split_once('=') else {
bail!("--resource wants KIND[/QUALIFIER]=SOURCE:VALUE, got {spec:?}");
};
let (head, scope) = match head.split_once('@') {
Some((head, scope)) => (head, Some(scope)),
None => (head, None),
};
let mut parts = head.split('/');
let kind = parts.next().unwrap_or_default();
if kind.is_empty() {
bail!("--resource {spec:?} has an empty kind");
}
let qualifiers: Vec<&str> = parts.collect();
let mut resource = Resource::new(kind);
match (kind, qualifiers.as_slice()) {
("completion", [shell]) if shell.contains(',') => {
resource.shells = shell
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
("completion", [shell]) => resource.shell = Some(shell.trim().to_string()),
("completion", [shell, bin]) => {
if shell.contains(',') {
resource.shells = shell
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
} else {
resource.shell = Some(shell.trim().to_string());
}
resource.bin = Some(bin.to_string());
}
("completion", _) => {
bail!("--resource {spec:?}: completion wants completion/SHELL[/BIN]")
}
("man", []) => {}
("man", [bin]) => resource.bin = Some(bin.to_string()),
("man", _) => bail!("--resource {spec:?}: man wants man[/BIN]"),
("cli-spec", [format]) => {
resource.format = Some(format.to_string());
let Some(bin) = default_bin else {
bail!("--resource {spec:?}: say which executable, as cli-spec/{format}/BIN");
};
resource.bin = Some(bin.to_string());
}
("cli-spec", [format, bin]) => {
resource.format = Some(format.to_string());
resource.bin = Some(bin.to_string());
}
("cli-spec", _) => bail!("--resource {spec:?}: cli-spec wants cli-spec/FORMAT[/BIN]"),
("skill", [name]) => resource.name = Some(name.to_string()),
("skill", _) => bail!("--resource {spec:?}: skill wants skill/NAME"),
("sbom", [format]) => resource.format = Some(format.to_string()),
("sbom", _) => bail!("--resource {spec:?}: sbom wants sbom/FORMAT (cyclonedx, spdx)"),
(_, []) => {}
(_, [name]) => resource.name = Some(name.to_string()),
(_, _) => bail!("--resource {spec:?}: {kind} takes at most one qualifier"),
}
if let Some(scope) = scope {
if !valid_scope(scope) {
bail!("--resource {spec:?}: the @ scope wants os[/arch[/libc]], got {scope:?}");
}
let mut fields = scope.split('/');
resource.os = fields.next().map(str::to_string);
resource.arch = fields.next().map(str::to_string);
resource.libc = fields.next().map(str::to_string);
}
let mut asset_path = None;
match value.split_once(':') {
Some(("archive", path)) => resource.archive = Some(path.to_string()),
Some(("repo", path)) => resource.repo = Some(path.to_string()),
Some(("exec", argv)) => {
let mut words = argv.split_whitespace().peekable();
while let Some((name, value)) = words.peek().and_then(|w| w.split_once('=')) {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
break;
}
resource.env.insert(name.to_string(), value.to_string());
words.next();
}
resource.exec = words.map(str::to_string).collect();
if resource.exec.is_empty() {
bail!("--resource {spec:?}: exec wants a command after any NAME=value words");
}
}
Some(("asset", path)) => {
let path = PathBuf::from(path);
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
bail!("--resource {spec:?}: asset wants a file path");
};
resource.asset = Some(name.to_string());
asset_path = Some(path);
}
_ => bail!(
"--resource {spec:?}: the value must start with archive:, asset:, repo:, or exec:"
),
}
Ok(ResourceSpec {
resource,
asset_path,
})
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn parse_require(spec: &str) -> Result<RequiredBin> {
let Some(rest) = spec.strip_prefix("bin:") else {
bail!("--require wants bin:NAME or bin:NAME@MIN, got {spec:?}");
};
let (name, min) = match rest.split_once('@') {
Some((name, min)) => (name, Some(min)),
None => (rest, None),
};
if name.is_empty() {
bail!("--require {spec:?} has an empty command name");
}
if min == Some("") {
bail!("--require {spec:?} has an empty minimum version");
}
Ok(match min {
Some(min) => RequiredBin::at_least(name, min),
None => RequiredBin::new(name),
})
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn valid_minimum(value: &str) -> bool {
value.as_bytes().first().is_some_and(u8::is_ascii_digit)
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+'))
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn parse_evidence(spec: &str) -> Evidence {
match spec.split_once('=') {
Some((kind, detail)) => Evidence {
kind: kind.to_string(),
detail: Some(detail.to_string()),
},
None => Evidence {
kind: spec.to_string(),
detail: None,
},
}
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn parse_extension(spec: &str) -> Result<(String, serde_json::Value)> {
let Some((name, json)) = spec.split_once('=') else {
bail!("--extension wants NAME=JSON, got {spec:?}");
};
if name.is_empty() {
bail!("--extension wants a NAME before the =, got {spec:?}");
}
let value = serde_json::from_str(json)
.wrap_err_with(|| format!("--extension {name}: the value is not JSON: {json:?}"))?;
Ok((name.to_string(), value))
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn file_name_of(path: &Path) -> &str {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
}
#[cfg(all(feature = "create", feature = "manifest"))]
impl RunWith<BinInfo> for Create {
type Output = Result<()>;
fn run_with(self, _: BinInfo) -> Self::Output {
let manifest = match &self.manifest {
Some(path) => Manifest::read(path)?,
None => Manifest::default(),
};
let Some(project) = self.project.clone().or_else(|| manifest.project.clone()) else {
bail!("--project is required unless the manifest names the project");
};
let Some(version) = self.version.clone().or_else(|| manifest.version.clone()) else {
bail!("--version is required unless the manifest names the version");
};
let manifest_source = manifest.source.as_ref();
let source_repo = self
.source_repo
.clone()
.or_else(|| manifest_source.map(|s| s.repo.clone()));
let commit = self
.commit
.clone()
.or_else(|| manifest_source.and_then(|s| s.commit.clone()));
let tag = self
.tag
.clone()
.or_else(|| manifest_source.and_then(|s| s.tag.clone()));
if source_repo.is_none() && (commit.is_some() || tag.is_some()) {
bail!("--commit and --tag require --source-repo");
}
let attested_by = self.attested_by.map(|a| a.0).unwrap_or_default();
if attested_by == Attestor::Vendor && !self.evidence.is_empty() {
bail!("--evidence describes what a repackager checked; pass --attested-by repackager");
}
let signer = signer(&self.key, self.sign, self.no_log)?;
let mut urls = std::collections::BTreeMap::new();
for spec in &self.url {
let Some((name, url)) = spec.split_once('=') else {
bail!("--url wants FILENAME=URL, got {spec:?}");
};
urls.insert(name.to_string(), url.to_string());
}
let mut formats = std::collections::BTreeMap::new();
for spec in &self.format {
let Some((name, format)) = spec.split_once('=') else {
bail!("--format wants FILENAME=FORMAT, got {spec:?}");
};
if format.is_empty() {
bail!("--format {spec:?} has an empty format");
}
formats.insert(name.to_string(), format.to_string());
}
let mut extensions = manifest.extensions.clone();
let mut from_flags = Extensions::new();
for spec in &self.extension {
let (name, value) = parse_extension(spec)?;
if from_flags.insert(name.clone(), value.clone()).is_some() {
bail!("--extension {name:?} is given twice");
}
extensions.insert(name, value);
}
let mut default_bins: Vec<Bin> = manifest.bin.clone();
default_bins.extend(self.bin.iter().map(|s| parse_bin(s)));
let mut requires_bin: Vec<RequiredBin> = Vec::new();
for spec in &self.require {
let required = parse_require(spec)?;
if requires_bin.iter().any(|r| r.name == required.name) {
bail!("--require names {:?} twice", required.name);
}
requires_bin.push(required);
}
let any_bin = !default_bins.is_empty()
|| manifest
.artifacts
.iter()
.any(|a| !a.bins(&default_bins).is_empty());
if !requires_bin.is_empty() && !any_bin {
bail!("--require says what the executables need; name them with --bin");
}
let parsed: Vec<ArtifactArg> = self.artifacts.iter().map(|s| parse_spec(s)).collect();
let mut provenance: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
let mut positional = Vec::new();
for spec in &self.provenance {
match spec.split_once('=') {
Some((name, url))
if !name.is_empty() && !name.contains('/') && !name.contains(':') =>
{
provenance.insert(name.to_string(), url.to_string());
}
_ => positional.push(spec.clone()),
}
}
for (arg, url) in parsed.iter().zip(positional) {
provenance
.entry(file_name_of(&arg.path).to_string())
.or_insert(url);
}
let provenance_of = |name: &str, own: &[String]| -> Vec<String> {
let mut all = own.to_vec();
if let Some(url) = provenance.get(name)
&& !all.contains(url)
{
all.push(url.clone());
}
all
};
let mut artifacts: Vec<ArtifactInput<'_>> = Vec::new();
for entry in &manifest.artifacts {
let name = file_name_of(&entry.path);
artifacts.push(ArtifactInput {
path: &entry.path,
os: entry.os.as_deref(),
arch: entry.arch.as_deref(),
libc: entry.libc.as_deref(),
portable: entry.portable,
variant: entry.variant.clone(),
url: entry.url.clone().or_else(|| urls.get(name).cloned()),
format: entry.format.clone().or_else(|| formats.get(name).cloned()),
bin: entry.bins(&default_bins).to_vec(),
requires: entry.requirements(manifest.requires.as_ref()),
provenance: provenance_of(name, &entry.provenance),
extensions: entry.extensions.clone(),
});
}
for arg in &parsed {
let name = file_name_of(&arg.path);
if manifest
.artifacts
.iter()
.any(|entry| file_name_of(&entry.path) == name)
{
continue;
}
artifacts.push(ArtifactInput {
path: &arg.path,
os: arg.os.as_deref(),
arch: arg.arch.as_deref(),
libc: arg.libc.as_deref(),
portable: arg.portable,
variant: arg.variant.clone(),
url: urls.get(name).cloned(),
format: formats.get(name).cloned(),
bin: default_bins.clone(),
requires: manifest.requires.clone(),
provenance: provenance_of(name, &[]),
extensions: Extensions::new(),
});
}
if artifacts.is_empty() {
bail!("no artifacts: give files on the command line or list them in the manifest");
}
for artifact in &artifacts {
let Some(requires) = &artifact.requires else {
continue;
};
for (field, value) in [
("os_min", &requires.os_min),
("glibc_min", &requires.glibc_min),
] {
if let Some(value) = value
&& !valid_minimum(value)
{
bail!(
"artifact {:?} requires {field} {value:?}; a minimum starts with a digit and holds only letters, digits, ., -, and +, as in 2.31 or 10.0.17763",
file_name_of(artifact.path)
);
}
}
}
for name in provenance.keys() {
if !artifacts.iter().any(|a| file_name_of(a.path) == name) {
bail!("--provenance names {name:?}, which is not among the artifacts");
}
}
let default_bin = match default_bins.as_slice() {
[only] => Some(only.name.as_str()),
_ => None,
};
let mut resources = Vec::new();
let mut asset_paths: Vec<PathBuf> = Vec::new();
let mut add_asset = |path: Option<PathBuf>| {
if let Some(path) = path
&& !asset_paths.contains(&path)
{
asset_paths.push(path);
}
};
for entry in &manifest.resources {
let (resource, asset_path) = entry.resolve()?;
add_asset(asset_path);
resources.push(resource);
}
for spec in &self.resource {
let parsed = parse_resource(spec, default_bin)?;
add_asset(parsed.asset_path);
resources.push(parsed.resource);
}
let assets: Vec<AssetInput<'_>> = asset_paths
.iter()
.map(|path| AssetInput {
path,
url: urls.get(file_name_of(path)).cloned(),
})
.collect();
for name in urls.keys() {
if !artifacts.iter().any(|a| file_name_of(a.path) == name)
&& !asset_paths.iter().any(|p| file_name_of(p) == name)
{
bail!("--url names {name:?}, which is not among the artifacts or assets");
}
}
for name in formats.keys() {
if !artifacts.iter().any(|a| file_name_of(a.path) == name) {
bail!("--format names {name:?}, which is not among the artifacts");
}
}
let source = source_repo.map(|repo| Source {
repo,
commit,
tag: tag.clone(),
});
if let Some(tag) = &tag
&& packslip::model::repository(&project).is_some()
&& packslip::model::tag_version(tag, &project).as_deref() != Some(version.as_str())
{
eprintln!(
"warning: tag {tag:?} does not name version {version} (a tag is the version, optionally after a v and the tool's or repository's name); consumers listing {project} from its tags will not see this release unless a signed release list names it"
);
}
let url_base = self.url_base.as_deref().or(manifest.url_base.as_deref());
let notes_url = self.notes_url.as_deref().or(manifest.notes_url.as_deref());
let published_at = self
.published_at
.as_deref()
.or(manifest.published_at.as_deref());
let created = packslip::create::create(&Request {
published_at,
source,
artifacts,
resources,
assets,
url_base,
notes_url,
extensions,
attested_by,
evidence: self.evidence.iter().map(|s| parse_evidence(s)).collect(),
sha512: !self.no_sha512,
requires_bin,
read_executables: !self.no_libs,
..Request::new(
&project,
&version,
declared_identity(&signer, self.no_pin_workflow)?,
)
})?;
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(&project));
std::fs::write(&path, &bundle)?;
let resource_count = created.statement.predicate.resources.len();
println!(
"wrote {} ({} artifact(s){}, signed by {}{}{})",
path.display(),
created.statement.predicate.artifacts.len(),
if resource_count > 0 {
format!(", {resource_count} resource(s)")
} else {
String::new()
},
identity.key_id,
if attested_by == Attestor::Repackager {
", repackager-attested"
} else {
""
},
if self.no_log { ", unlogged" } else { "" }
);
for artifact in &created.statement.predicate.artifacts {
if let Some(requires) = artifact.requires.as_ref().filter(|r| !r.is_empty()) {
println!(" requires {}: {}", artifact.name, requires.summary());
}
}
Ok(())
}
}
#[cfg(all(feature = "create", feature = "manifest"))]
struct ArtifactArg {
path: PathBuf,
os: Option<String>,
arch: Option<String>,
libc: Option<String>,
portable: bool,
variant: Option<String>,
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn parse_spec(spec: &str) -> ArtifactArg {
let (rest, variant) = match spec.rsplit_once('@') {
Some((rest, variant)) if valid_word(variant) && !rest.is_empty() => {
(rest, Some(variant.to_string()))
}
_ => (spec, None),
};
let plain = |path: &str, portable: bool| ArtifactArg {
path: PathBuf::from(path),
os: None,
arch: None,
libc: None,
portable,
variant: variant.clone(),
};
match rest.rsplit_once(':') {
Some((path, "any")) if !path.is_empty() => plain(path, true),
Some((path, platform)) if valid_platform(platform) => {
let mut parts = platform.split('/');
ArtifactArg {
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),
portable: false,
variant,
}
}
_ => plain(rest, false),
}
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn valid_word(part: &str) -> bool {
part.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
&& part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}
#[cfg(all(feature = "create", feature = "manifest"))]
fn valid_scope(scope: &str) -> bool {
let parts: Vec<_> = scope.split('/').collect();
(1..=3).contains(&parts.len()) && parts.iter().all(|part| valid_word(part))
}
#[cfg(all(feature = "create", feature = "manifest"))]
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)]
#[usage(example(
"packslip releases \\\n --project mytool.example.com \\\n --sequence \"$(date +%s)\" --valid-for 30d \\\n --latest 1.2.3 \\\n --release https://mytool.example.com/v1.2.3/packslip.sigstore.json=releases/v1.2.3/packslip.sigstore.json \\\n --key release.key \\\n --out site/.well-known/packslip.json",
header = "List one key-signed release of a domain project"
))]
#[cfg(all(feature = "create", feature = "manifest"))]
struct Releases {
#[usage(long)]
project: String,
#[usage(long)]
sequence: u64,
#[usage(long)]
latest: Option<String>,
#[usage(long, default = "30d")]
valid_for: String,
#[usage(long)]
generated_at: Option<String>,
#[usage(long)]
release: Vec<String>,
#[usage(long)]
yank: Vec<String>,
#[usage(long)]
security: Vec<String>,
#[usage(long)]
evidence: Vec<String>,
#[usage(short = 'k', long, value_hint = usage_rs::ValueHint::FilePath, help_heading = "Signing")]
key: Option<PathBuf>,
#[usage(long, help_heading = "Signing")]
sign: Option<SignWith>,
#[usage(long, help_heading = "Signing")]
no_log: bool,
#[usage(long, help_heading = "Signing")]
no_pin_workflow: bool,
#[usage(short = 'o', long, default = "packslip-releases.sigstore.json")]
out: PathBuf,
}
#[cfg(all(feature = "create", feature = "manifest"))]
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 mut yanked = std::collections::BTreeMap::new();
for spec in &self.yank {
let Some((url, reason)) = spec.split_once('=') else {
bail!("--yank wants URL=REASON, got {spec:?}");
};
yanked.insert(url.to_string(), reason.to_string());
}
let mut evidence: std::collections::BTreeMap<String, Vec<Evidence>> =
std::collections::BTreeMap::new();
for spec in &self.evidence {
let Some((url, rest)) = spec.split_once('=') else {
bail!("--evidence wants URL=KIND or URL=KIND=DETAIL, got {spec:?}");
};
evidence
.entry(url.to_string())
.or_default()
.push(parse_evidence(rest));
}
for url in yanked
.keys()
.chain(self.security.iter())
.chain(evidence.keys())
{
if !pairs.iter().any(|(u, _)| u == url) {
bail!("{url} is not among the --release entries");
}
}
let releases = pairs
.iter()
.map(|(url, path)| ListedRelease {
url,
bundle_path: path,
yanked: yanked.get(url).cloned(),
security: self.security.contains(url),
evidence: evidence.get(url).cloned().unwrap_or_default(),
})
.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,
latest: self.latest.as_deref(),
releases,
identity: declared_identity(&signer, self.no_pin_workflow)?,
})?;
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(())
}
}
#[cfg(all(feature = "create", feature = "manifest"))]
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)]
#[usage(
exit_code(0, "The bundle verified, and every --artifact matched it"),
exit_code(
1,
"Verification failed, or an input was unusable: a file could not be read, a key or fingerprint is malformed, or the trust flags conflict or are missing"
),
exit_code(2, "The command line is invalid"),
example(
"packslip verify packslip.sigstore.json \\\n --artifact mytool-1.2.3-linux-x64.tar.gz",
header = "Verify a GitHub release and a downloaded artifact"
),
example(
"packslip verify packslip.sigstore.json \\\n --pin ps1_snirenkjwr7m5ozgcufameodnm \\\n --artifact hk-x86_64-unknown-linux-gnu.tar.gz",
header = "Verify hk 2.8.0 against its signer fingerprint"
),
example(
"packslip verify packslip.sigstore.json \\\n --issuer https://token.actions.githubusercontent.com \\\n --identity-prefix https://github.com/repackager/mytool/ \\\n --allow-other-repository",
header = "Verify a release a repackager signed from its own repository"
),
example(
"packslip verify packslip.sigstore.json \\\n --pubkey release.pub \\\n --artifact mytool-1.2.3-linux-x64.tar.gz",
header = "Verify a key-signed release"
)
)]
struct Verify {
#[usage(value_hint = usage_rs::ValueHint::FilePath)]
bundle: PathBuf,
#[usage(short = 'p', long, help_heading = "Trust")]
pubkey: Option<String>,
#[usage(long, help_heading = "Trust")]
identity: Option<String>,
#[usage(long, help_heading = "Trust")]
identity_prefix: Option<String>,
#[usage(long, help_heading = "Trust")]
issuer: Option<String>,
#[usage(long, help_heading = "Trust")]
source_repository: Option<String>,
#[usage(long, help_heading = "Trust")]
pin: Option<String>,
#[usage(long, help_heading = "Trust")]
allow_other_repository: bool,
#[usage(long, help_heading = "Trust")]
allow_unlogged: bool,
#[usage(long, value_hint = usage_rs::ValueHint::FilePath, help_heading = "Trust")]
trusted_root: Option<PathBuf>,
#[usage(short = 'a', long)]
artifact: Vec<PathBuf>,
#[usage(short = 'J', long, help_heading = "Output")]
json: bool,
}
enum TrustPin {
Key(PublicKey),
Identity(Policy),
}
impl TrustPin {
fn as_trust(&self) -> Trust<'_> {
match self {
TrustPin::Key(key) => Trust::Key(key),
TrustPin::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();
let is_list = peeked["predicateType"] == RELEASES_PREDICATE_TYPE;
let want_fingerprint: Option<Fingerprint> = match &self.pin {
Some(pin) => Some(pin.parse().wrap_err("--pin")?),
None => None,
};
if self.allow_other_repository
&& self.pubkey.is_none()
&& self.identity.is_none()
&& self.identity_prefix.is_none()
&& self.source_repository.is_none()
{
bail!(
"--allow-other-repository accepts a signer outside the project's repository, so name that signer with --identity, --identity-prefix, or --source-repository"
);
}
if self.allow_other_repository && self.pubkey.is_none() && self.issuer.is_none() {
bail!(
"--allow-other-repository needs --issuer: a signer outside the project's repository is only as good as the issuer that vouches for it"
);
}
let pin = match &self.pubkey {
Some(text) => {
if self.identity.is_some()
|| self.identity_prefix.is_some()
|| self.issuer.is_some()
|| self.source_repository.is_some()
{
bail!(
"--pubkey pins a key; the identity flags pin a certificate. Pass one kind"
);
}
if want_fingerprint.is_some() {
bail!(
"--pin is the fingerprint of a certificate's repository; a key has none. Use --pubkey alone"
);
}
if self.allow_other_repository {
bail!(
"--allow-other-repository is about a certificate's repository; a key has none. Use --pubkey alone"
);
}
let pubkey_text = if Path::new(text).is_file() {
std::fs::read_to_string(text)?
} else {
text.clone()
};
TrustPin::Key(PublicKey::parse(&pubkey_text)?)
}
None if self.source_repository.is_some() => {
if self.identity.is_some() || self.identity_prefix.is_some() {
bail!(
"--source-repository pins the repository, not a workflow; drop --identity and --identity-prefix"
);
}
let Some(issuer) = &self.issuer else {
bail!(
"--source-repository needs --issuer: the repository is only as good as the issuer that vouches for it"
);
};
TrustPin::Identity(Policy {
issuer: Some(issuer.clone()),
identity: None,
identity_prefix: None,
})
}
None => TrustPin::Identity(identity_policy(
&self.identity,
&self.identity_prefix,
&self.issuer,
project,
sigstore::Error::NoPolicy,
)?),
};
let trusted_root = load_trusted_root(self.trusted_root.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();
let keyless = matches!(pin, TrustPin::Identity(_));
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;
let source = sigstore::source_repository(&text)?;
if keyless
&& let Err(err) = self.check_signer(
&list.project,
&verified.key_id,
verified.issuer.as_deref(),
source.as_ref(),
want_fingerprint.as_ref(),
)
{
eprintln!("verification failed: {err}");
std::process::exit(1)
}
if self.json {
println!("{}", serde_json::to_string_pretty(&verified.list)?);
} else {
let yanked = list.releases.iter().filter(|r| r.is_yanked()).count();
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(),
if yanked > 0 {
format!(", {yanked} yanked")
} else {
String::new()
}
);
if let Some(source) = &source {
println!(" repository {}", describe_source(source));
}
}
Ok(())
}
Err(err) => {
eprintln!("verification failed: {err}");
std::process::exit(1)
}
};
}
match packslip::verify(&text, &pin.as_trust(), options, &artifacts) {
Ok(verified) => {
let source = sigstore::source_repository(&text)?;
if keyless
&& let Err(err) = self.check_signer(
&verified.project,
&verified.key_id,
verified.issuer.as_deref(),
source.as_ref(),
want_fingerprint.as_ref(),
)
{
eprintln!("verification failed: {err}");
std::process::exit(1)
}
if self.json {
let report = packslip::VerifyReport {
verified: &verified,
source_repository: source.as_ref(),
};
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"ok: {} {}{} published {} signed by {} ({}){}{} ({}{} checked{}{})",
verified.project,
verified.version,
match (&verified.channel, verified.prerelease) {
(Some(channel), _) => format!(" ({channel} prerelease)"),
(None, true) => " (prerelease)".to_string(),
(None, false) => String::new(),
},
verified.published_at,
verified.key_id,
verified.scheme,
if verified.attested_by == Attestor::Repackager {
" repackager-attested"
} else {
""
},
match &verified.logged_at {
Some(at) => format!(" logged {at}"),
None => " unlogged".to_string(),
},
format_args!(
"{} of {} artifact(s)",
verified.checked_artifacts.len(),
verified.artifact_count
),
if verified.asset_count == 0 {
String::new()
} else {
format!(
" and {} of {} asset(s)",
verified.checked_assets.len(),
verified.asset_count
)
},
if verified.provenance_linked {
", provenance linked"
} else {
""
},
if verified.resources.is_empty() {
String::new()
} else {
format!(", {} resource(s)", verified.resources.len())
}
);
if let Some(source) = &source {
println!(" repository {}", describe_source(source));
}
for line in &verified.requires {
println!(" requires {line}");
}
}
Ok(())
}
Err(err) => {
eprintln!("verification failed: {err}");
std::process::exit(1)
}
}
}
}
impl Verify {
fn check_signer(
&self,
project: &str,
signer: &str,
issuer: Option<&str>,
source: Option<&sigstore::SourceRepository>,
want_fingerprint: Option<&Fingerprint>,
) -> std::result::Result<(), String> {
if let Some(want) = self.source_repository.as_deref() {
require_source_repository(want, source)?;
}
if !self.allow_other_repository {
signed_from_project(project, signer, issuer, source, &self.narrowing_flags())?;
}
if let Some(want) = want_fingerprint {
want.verify(issuer, source).map_err(|e| e.to_string())?;
}
Ok(())
}
fn narrowing_flags(&self) -> Vec<&'static str> {
[
("--identity", self.identity.is_some()),
("--identity-prefix", self.identity_prefix.is_some()),
("--issuer", self.issuer.is_some()),
("--source-repository", self.source_repository.is_some()),
("--pin", self.pin.is_some()),
]
.into_iter()
.filter_map(|(flag, passed)| passed.then_some(flag))
.collect()
}
}
fn require_source_repository(
want: &str,
source: Option<&sigstore::SourceRepository>,
) -> std::result::Result<(), String> {
let wanted = |uri: &str| {
let want = want.trim_end_matches('/');
match (uri.strip_prefix("https://"), want.strip_prefix("https://")) {
(Some(uri), Some(want)) => packslip::forge::same_project(uri, want),
_ => uri == want,
}
};
match source {
Some(source) if wanted(&source.uri) => Ok(()),
Some(source) => Err(format!(
"the certificate's source repository is {}, expected {want}",
source.uri
)),
None => Err(format!(
"the certificate records no source repository, expected {want}"
)),
}
}
fn signed_from_project(
project: &str,
signer: &str,
issuer: Option<&str>,
source: Option<&sigstore::SourceRepository>,
flags: &[&str],
) -> std::result::Result<(), String> {
use packslip::forge::IdentityError;
let reason = match bound_to_project(project, signer, issuer, source) {
Ok(()) => return Ok(()),
Err(IdentityError::SourceMismatch {
certificate,
repository,
}) => format!(
"{project} must be signed from its repository {repository}, but {signer} signed it from {certificate}"
),
Err(IdentityError::SignerOutsideRepository { repository, .. }) => format!(
"{project} must be signed from its repository {repository}, but {signer} is not one of its workflows"
),
Err(IdentityError::Issuer {
expected, actual, ..
}) => format!(
"{project} must be signed from its repository through {expected}, but {signer} signed it through {}",
actual.as_deref().unwrap_or("no issuer")
),
Err(err) => err.to_string(),
};
let remedy = "accept a signer outside that repository, name it with --issuer and --identity, --identity-prefix, or --source-repository, and add --allow-other-repository";
let named = match flags {
[] => return Err(format!("{reason}. To {remedy}")),
[flag] => {
return Err(format!(
"{reason}. {flag} narrows what the project's name requires and does not replace it; to {remedy}"
));
}
[first, last] => format!("{first} and {last}"),
[init @ .., last] => format!("{}, and {last}", init.join(", ")),
};
Err(format!(
"{reason}. {named} narrow what the project's name requires and do not replace it; to {remedy}"
))
}
fn identity_policy(
identity: &Option<String>,
identity_prefix: &Option<String>,
issuer: &Option<String>,
project: &str,
missing: fn(String) -> sigstore::Error,
) -> Result<Policy> {
let explicit = Policy {
issuer: issuer.clone(),
identity: identity.clone(),
identity_prefix: identity_prefix.clone(),
};
if explicit.is_empty() {
let derived = Policy::for_project(project)
.and_then(|_| packslip::forge::policy(project, project))
.ok_or_else(|| missing(project.into()))?;
Ok(derived)
} else {
Ok(explicit)
}
}
fn load_trusted_root(path: Option<&Path>) -> Result<sigstore_trust_root::TrustedRoot> {
let json = match path {
Some(path) => Some(
std::fs::read_to_string(path)
.wrap_err_with(|| format!("reading {}", path.display()))?,
),
None => None,
};
Ok(sigstore::trusted_root(json.as_deref())?)
}
fn bound_to_project(
project: &str,
signer: &str,
issuer: Option<&str>,
source: Option<&sigstore::SourceRepository>,
) -> std::result::Result<(), packslip::forge::IdentityError> {
match packslip::forge::check_source(
&packslip::forge::Expected::new(project),
project,
signer,
issuer,
source,
) {
Ok(_) | Err(packslip::forge::IdentityError::NotForge(_)) => Ok(()),
Err(err) => Err(err),
}
}
fn signer_fingerprint(
project: &str,
signer: &str,
issuer: Option<&str>,
source: Option<&sigstore::SourceRepository>,
) -> std::result::Result<Fingerprint, String> {
if issuer.is_none() {
return Err(format!(
"{project} is signed with a key, which has no signer fingerprint; pin it with --pubkey"
));
}
bound_to_project(project, signer, issuer, source).map_err(|e| e.to_string())?;
Fingerprint::of_signer(issuer, source).ok_or_else(|| {
format!(
"{project}'s certificate records no repository ID, so it has no signer fingerprint; pin it with --identity-prefix and --issuer instead"
)
})
}
#[derive(Debug, usage_rs::Args)]
#[usage(
exit_code(0, "Printed the signer fingerprint"),
exit_code(
1,
"Verification failed, the release has no signer fingerprint, or an input was unusable"
),
exit_code(2, "The command line is invalid"),
example(
"packslip pin packslip.sigstore.json",
header = "Print the fingerprint of a release you already trust"
)
)]
struct Pin {
#[usage(value_hint = usage_rs::ValueHint::FilePath)]
bundle: PathBuf,
#[usage(long, help_heading = "Trust")]
identity: Option<String>,
#[usage(long, help_heading = "Trust")]
identity_prefix: Option<String>,
#[usage(long, help_heading = "Trust")]
issuer: Option<String>,
#[usage(long, help_heading = "Trust")]
allow_unlogged: bool,
#[usage(long, value_hint = usage_rs::ValueHint::FilePath, help_heading = "Trust")]
trusted_root: Option<PathBuf>,
}
impl RunWith<BinInfo> for Pin {
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")?;
if peeked["predicateType"] == RELEASES_PREDICATE_TYPE {
bail!("a release list is not a release; give a release's packslip.sigstore.json");
}
let project = peeked["predicate"]["project"].as_str().unwrap_or_default();
let policy = identity_policy(
&self.identity,
&self.identity_prefix,
&self.issuer,
project,
sigstore::Error::NoIdentityPolicy,
)?;
let trusted_root = load_trusted_root(self.trusted_root.as_deref())?;
let options = Options {
require_log: !self.allow_unlogged,
trusted_root: &trusted_root,
};
let verified = match packslip::verify(&text, &Trust::Identity(&policy), options, &[]) {
Ok(verified) => verified,
Err(err) => {
eprintln!("verification failed: {err}");
std::process::exit(1)
}
};
let source = sigstore::source_repository(&text)?;
match signer_fingerprint(
&verified.project,
&verified.key_id,
verified.issuer.as_deref(),
source.as_ref(),
) {
Ok(fingerprint) => {
println!("{fingerprint}");
Ok(())
}
Err(err) => {
eprintln!("{err}");
std::process::exit(1)
}
}
}
}
fn describe_source(source: &sigstore::SourceRepository) -> String {
let with_id = |uri: &str, id: &Option<String>| match id {
Some(id) => format!("{uri} (id {id})"),
None => uri.to_string(),
};
let mut line = with_id(&source.uri, &source.id);
if let Some(owner) = &source.owner_uri {
line.push_str(&format!(", owner {}", with_id(owner, &source.owner_id)));
}
line
}
fn main() -> Result<()> {
color_eyre::install()?;
let args: Vec<_> = std::env::args_os().collect();
if let Some(answer) = Cli::completion_request(&args[1..]) {
print!("{answer}");
return Ok(());
}
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(all(test, feature = "create", feature = "manifest"))]
mod tests {
use super::*;
#[test]
fn artifact_specs() {
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());
assert!(spec.variant.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@fips");
assert_eq!(spec.libc.as_deref(), Some("musl"));
assert_eq!(spec.variant.as_deref(), Some("fips"));
let spec = parse_spec("tool-fips.tar.gz@fips");
assert_eq!(spec.path, PathBuf::from("tool-fips.tar.gz"));
assert_eq!(spec.variant.as_deref(), Some("fips"));
let spec = parse_spec("scoped@pkg-1.0.tgz");
assert_eq!(spec.path, PathBuf::from("scoped@pkg-1.0.tgz"));
assert!(spec.variant.is_none(), "a variant is a plain word");
let spec = parse_spec("tool.jar:any");
assert_eq!(spec.path, PathBuf::from("tool.jar"));
assert!(spec.portable);
let spec = parse_spec("tool-linux.jar:any@slim");
assert!(spec.portable);
assert_eq!(spec.variant.as_deref(), Some("slim"));
}
#[test]
fn bins_and_evidence_parse() {
assert_eq!(parse_bin("bin/tool"), Bin::new("bin/tool"));
assert_eq!(
parse_bin("oxlint=bin/oxlint-x86_64"),
Bin::named("bin/oxlint-x86_64", "oxlint")
);
assert_eq!(parse_evidence("pkgbuild-checksums").detail, None);
assert_eq!(
parse_evidence("apt-release-gpg=3FEF9748").detail.as_deref(),
Some("3FEF9748")
);
}
#[test]
fn resources_parse() {
let r = parse_resource(
"completion/zsh=archive:share/zsh/site-functions/_tool",
None,
)
.unwrap();
assert_eq!(r.resource.kind, "completion");
assert_eq!(r.resource.shell.as_deref(), Some("zsh"));
assert_eq!(
r.resource.archive.as_deref(),
Some("share/zsh/site-functions/_tool")
);
assert!(r.asset_path.is_none());
let r = parse_resource(
"completion/bash,zsh,fish=exec:tool completion {shell}",
None,
)
.unwrap();
assert_eq!(r.resource.shells, ["bash", "zsh", "fish"]);
assert_eq!(r.resource.exec, ["tool", "completion", "{shell}"]);
let r = parse_resource(
"completion/bash,zsh=exec:COMPLETE={shell} _TOOL_X=1 tool",
None,
)
.unwrap();
assert_eq!(r.resource.exec, ["tool"]);
assert_eq!(r.resource.env["COMPLETE"], "{shell}");
assert_eq!(r.resource.env["_TOOL_X"], "1");
assert!(parse_resource("man=exec:ONLY=env", None).is_err());
let r = parse_resource(
"completion/bash, zsh,,fish =exec:tool completion {shell}",
None,
)
.unwrap();
assert_eq!(r.resource.shells, ["bash", "zsh", "fish"]);
let r = parse_resource("cli-spec/usage=exec:tool usage", Some("tool")).unwrap();
assert_eq!(r.resource.format.as_deref(), Some("usage"));
assert_eq!(r.resource.bin.as_deref(), Some("tool"));
let r = parse_resource("cli-spec/usage/other=repo:specs/other.kdl", Some("tool")).unwrap();
assert_eq!(r.resource.bin.as_deref(), Some("other"));
assert_eq!(r.resource.repo.as_deref(), Some("specs/other.kdl"));
let r = parse_resource("completion/zsh/other=archive:share/_other", None).unwrap();
assert_eq!(r.resource.shell.as_deref(), Some("zsh"));
assert_eq!(r.resource.bin.as_deref(), Some("other"));
let r = parse_resource(
"completion/bash,zsh/other=exec:other completion {shell}",
None,
)
.unwrap();
assert_eq!(r.resource.shells, ["bash", "zsh"]);
assert_eq!(r.resource.bin.as_deref(), Some("other"));
let r = parse_resource("man/other=archive:share/man/other.1", None).unwrap();
assert_eq!(r.resource.bin.as_deref(), Some("other"));
assert!(r.resource.name.is_none());
assert!(parse_resource("cli-spec/usage=repo:x", None).is_err());
let r = parse_resource("skill/tool=asset:dist/tool-skill.tar.gz", None).unwrap();
assert_eq!(r.resource.name.as_deref(), Some("tool"));
assert_eq!(r.resource.asset.as_deref(), Some("tool-skill.tar.gz"));
assert_eq!(r.asset_path, Some(PathBuf::from("dist/tool-skill.tar.gz")));
let r = parse_resource("man=archive:man/man1/tool.1", None).unwrap();
assert!(r.resource.name.is_none());
let r = parse_resource("font/Tool=archive:fonts/Tool.ttf", None).unwrap();
assert_eq!(r.resource.name.as_deref(), Some("Tool"));
let r = parse_resource("sbom/cyclonedx=asset:dist/tool.cdx.json", None).unwrap();
assert_eq!(r.resource.format.as_deref(), Some("cyclonedx"));
assert_eq!(r.resource.name, None);
assert_eq!(r.resource.asset.as_deref(), Some("tool.cdx.json"));
assert!(parse_resource("sbom=asset:dist/tool.cdx.json", None).is_err());
let r = parse_resource("man@linux=archive:share/man/man1/tool.1", None).unwrap();
assert_eq!(r.resource.os.as_deref(), Some("linux"));
assert_eq!(r.resource.arch, None);
assert_eq!(r.resource.libc, None);
assert_eq!(r.resource.archive.as_deref(), Some("share/man/man1/tool.1"));
let r = parse_resource("completion/zsh@linux/x86_64/musl=archive:_tool", None).unwrap();
assert_eq!(r.resource.shell.as_deref(), Some("zsh"));
assert_eq!(
(
r.resource.os.as_deref(),
r.resource.arch.as_deref(),
r.resource.libc.as_deref()
),
(Some("linux"), Some("x86_64"), Some("musl"))
);
let r = parse_resource("cli-spec/usage/tool=exec:tool usage @all", None).unwrap();
assert_eq!(r.resource.os, None);
assert_eq!(r.resource.exec, ["tool", "usage", "@all"]);
let r = parse_resource("skill/tool=repo:skills/@tool", None).unwrap();
assert_eq!(r.resource.repo.as_deref(), Some("skills/@tool"));
assert_eq!(r.resource.os, None);
for bad in [
"man",
"=archive:x",
"man=x",
"man=ftp:x",
"completion=archive:x",
"skill=archive:x",
"man/a/b=archive:x",
"man@=archive:x",
"man@linux/x86_64/gnu/extra=archive:x",
"man@linux//gnu=archive:x",
"man@1inux=archive:x",
] {
assert!(parse_resource(bad, None).is_err(), "{bad}");
}
}
#[test]
fn bundle_names() {
assert_eq!(bundle_name("github.com/jdx/mise"), "packslip.sigstore.json");
assert_eq!(bundle_name("mise.jdx.dev"), "packslip.sigstore.json");
assert_eq!(bundle_name("example.com/tool"), "packslip.sigstore.json");
assert_eq!(
bundle_name("github.com/oxc-project/oxc/oxlint"),
"packslip.oxlint.sigstore.json"
);
assert_eq!(
bundle_name("github.com/biomejs/biome/crates/cli"),
"packslip.crates-cli.sigstore.json"
);
}
#[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());
}
}
#[cfg(test)]
mod signer_tests {
use super::*;
use packslip::sigstore::{GITHUB_ISSUER, SourceRepository};
const HK: &str = include_str!("../tests/fixtures/hk-v2.3.0.sigstore.json");
const HK_SIGNER: &str =
"https://github.com/jdx/hk/.github/workflows/release.yml@refs/tags/v2.3.0";
const HK_PIN: &str = "ps1_snirenkjwr7m5ozgcufameodnm";
fn issuer_alone(source_repository: Option<&str>, allow_other_repository: bool) -> Verify {
Verify {
bundle: PathBuf::from("packslip.sigstore.json"),
pubkey: None,
identity: None,
identity_prefix: None,
issuer: Some(GITHUB_ISSUER.into()),
source_repository: source_repository.map(str::to_string),
pin: None,
allow_other_repository,
allow_unlogged: false,
trusted_root: None,
artifact: Vec::new(),
json: false,
}
}
fn no_flags() -> Verify {
Verify {
issuer: None,
..issuer_alone(None, false)
}
}
const REMEDY: &str = "accept a signer outside that repository, name it with --issuer and --identity, --identity-prefix, or --source-repository, and add --allow-other-repository";
fn hk_under_issuer_alone() -> (String, Option<String>, Option<SourceRepository>) {
let policy = identity_policy(
&None,
&None,
&Some(GITHUB_ISSUER.into()),
"github.com/jdx/hk",
sigstore::Error::NoPolicy,
)
.unwrap();
assert_eq!(policy.identity_prefix, None);
let root = sigstore::trusted_root(None).unwrap();
let options = Options {
require_log: true,
trusted_root: &root,
};
let verified = packslip::verify(HK, &Trust::Identity(&policy), options, &[]).unwrap();
assert_eq!(verified.key_id, HK_SIGNER);
let source = sigstore::source_repository(HK).unwrap();
(verified.key_id, verified.issuer, source)
}
#[test]
fn issuer_alone_accepts_only_the_forge_projects_repository() {
let (signer, issuer, source) = hk_under_issuer_alone();
let check = |project: &str| {
issuer_alone(None, false).check_signer(
project,
&signer,
issuer.as_deref(),
source.as_ref(),
None,
)
};
check("github.com/jdx/hk").unwrap();
check("github.com/jdx/hk/tool").unwrap();
let err = check("github.com/jdx/mise").unwrap_err();
assert_eq!(
err,
format!(
"github.com/jdx/mise must be signed from its repository https://github.com/jdx/mise, but {HK_SIGNER} signed it from https://github.com/jdx/hk. --issuer narrows what the project's name requires and does not replace it; to {REMEDY}"
)
);
let err = check("gitlab.com/jdx/hk").unwrap_err();
assert!(
err.starts_with(&format!(
"gitlab.com/jdx/hk must be signed from its repository through https://gitlab.com, but {HK_SIGNER} signed it through {GITHUB_ISSUER}. "
)),
"{err}"
);
}
#[test]
fn without_flags_another_repositorys_certificate_is_refused() {
let policy = identity_policy(
&None,
&None,
&None,
"github.com/jdx/mise",
sigstore::Error::NoPolicy,
)
.unwrap();
assert_eq!(
policy,
Policy {
issuer: Some(GITHUB_ISSUER.into()),
identity: None,
identity_prefix: None,
}
);
let root = sigstore::trusted_root(None).unwrap();
let options = Options {
require_log: true,
trusted_root: &root,
};
let verified = packslip::verify(HK, &Trust::Identity(&policy), options, &[]).unwrap();
let source = sigstore::source_repository(HK).unwrap();
let (signer, issuer) = (verified.key_id.as_str(), verified.issuer.as_deref());
let no_flags = Verify {
issuer: None,
..issuer_alone(None, false)
};
no_flags
.check_signer("github.com/jdx/hk", signer, issuer, source.as_ref(), None)
.unwrap();
let err = no_flags
.check_signer("github.com/jdx/mise", signer, issuer, source.as_ref(), None)
.unwrap_err();
assert!(
err.starts_with(&format!(
"github.com/jdx/mise must be signed from its repository https://github.com/jdx/mise, but {HK_SIGNER} signed it from https://github.com/jdx/hk. "
)),
"{err}"
);
assert_eq!(
signer_fingerprint("github.com/jdx/hk", signer, issuer, source.as_ref())
.unwrap()
.to_string(),
HK_PIN
);
let err =
signer_fingerprint("github.com/jdx/mise", signer, issuer, source.as_ref()).unwrap_err();
assert_eq!(
err,
"the certificate's source repository is https://github.com/jdx/hk, not https://github.com/jdx/mise"
);
}
#[test]
fn allow_other_repository_lifts_only_the_projects_repository() {
let (signer, issuer, source) = hk_under_issuer_alone();
let check = |verify: Verify, pin: Option<&str>| {
let pin: Option<Fingerprint> = pin.map(|p| p.parse().unwrap());
verify.check_signer(
"github.com/jdx/mise",
&signer,
issuer.as_deref(),
source.as_ref(),
pin.as_ref(),
)
};
check(issuer_alone(None, true), None).unwrap();
check(issuer_alone(Some("https://github.com/jdx/hk"), true), None).unwrap();
check(issuer_alone(None, true), Some(HK_PIN)).unwrap();
let err = check(
issuer_alone(Some("https://github.com/jdx/mise"), true),
None,
)
.unwrap_err();
assert_eq!(
err,
"the certificate's source repository is https://github.com/jdx/hk, expected https://github.com/jdx/mise"
);
let err = check(
issuer_alone(None, true),
Some("ps1_kwhjac5qpc45qetfh6ppwisi6a"),
)
.unwrap_err();
assert!(
err.contains("but the pin is ps1_kwhjac5qpc45qetfh6ppwisi6a"),
"{err}"
);
let err = check(issuer_alone(Some("https://github.com/jdx/hk"), false), None).unwrap_err();
assert!(
err.ends_with(&format!(
". --issuer and --source-repository narrow what the project's name requires and do not replace it; to {REMEDY}"
)),
"{err}"
);
let pinned = |source_repository| Verify {
pin: Some(HK_PIN.into()),
..issuer_alone(source_repository, false)
};
let err = check(pinned(None), Some(HK_PIN)).unwrap_err();
assert!(
err.ends_with(&format!(
". --issuer and --pin narrow what the project's name requires and do not replace it; to {REMEDY}"
)),
"{err}"
);
let err = check(pinned(Some("https://github.com/jdx/hk")), Some(HK_PIN)).unwrap_err();
assert!(
err.ends_with(&format!(
". --issuer, --source-repository, and --pin narrow what the project's name requires and do not replace it; to {REMEDY}"
)),
"{err}"
);
}
#[test]
fn a_reusable_workflow_signs_for_the_repository_whose_run_called_it() {
let shared = "https://github.com/suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@refs/heads/main";
let caller = SourceRepository::new("https://github.com/suzuki-shunsuke/mkghtag");
let check = |project: &str, signer: &str, source: Option<&SourceRepository>| {
issuer_alone(None, false).check_signer(
project,
signer,
Some(GITHUB_ISSUER),
source,
None,
)
};
check("github.com/suzuki-shunsuke/mkghtag", shared, Some(&caller)).unwrap();
issuer_alone(Some("https://github.com/suzuki-shunsuke/mkghtag"), false)
.check_signer(
"github.com/suzuki-shunsuke/mkghtag",
shared,
Some(GITHUB_ISSUER),
Some(&caller),
None,
)
.unwrap();
let own = "https://github.com/jdx/hk/.github/workflows/release.yml@refs/heads/main";
let elsewhere = SourceRepository::new("https://github.com/someone/else");
let err = check("github.com/jdx/hk", own, Some(&elsewhere)).unwrap_err();
assert!(
err.starts_with(&format!(
"github.com/jdx/hk must be signed from its repository https://github.com/jdx/hk, but {own} signed it from https://github.com/someone/else. "
)),
"{err}"
);
let err = no_flags()
.check_signer(
"github.com/jdx/hk",
own,
Some(GITHUB_ISSUER),
Some(&elsewhere),
None,
)
.unwrap_err();
assert_eq!(
err,
format!(
"github.com/jdx/hk must be signed from its repository https://github.com/jdx/hk, but {own} signed it from https://github.com/someone/else. To {REMEDY}"
)
);
let err = check("github.com/suzuki-shunsuke/mkghtag", shared, None).unwrap_err();
assert!(
err.starts_with(&format!(
"github.com/suzuki-shunsuke/mkghtag must be signed from its repository https://github.com/suzuki-shunsuke/mkghtag, but {shared} is not one of its workflows. "
)),
"{err}"
);
}
#[test]
fn a_project_on_another_host_is_held_to_the_flags_alone() {
let (signer, issuer, source) = hk_under_issuer_alone();
for project in ["hk.jdx.dev", "example.com/tools/hk"] {
issuer_alone(None, false)
.check_signer(project, &signer, issuer.as_deref(), source.as_ref(), None)
.unwrap();
}
}
#[test]
fn without_flags_a_forge_project_is_verified_under_its_issuer_alone() {
let derived = |project: &str| {
identity_policy(&None, &None, &None, project, sigstore::Error::NoPolicy)
};
let issuer_alone = |issuer: &str| Policy {
issuer: Some(issuer.into()),
identity: None,
identity_prefix: None,
};
for project in ["github.com/jdx/hk", "github.com/oxc-project/oxc/oxlint"] {
assert_eq!(derived(project).unwrap(), issuer_alone(GITHUB_ISSUER));
}
assert_eq!(
derived("gitlab.com/group/sub/tool").unwrap(),
issuer_alone(packslip::sigstore::GITLAB_ISSUER)
);
for project in ["hk.jdx.dev", "github.com/jdx", "gitlab.com/group"] {
let err = derived(project).unwrap_err().to_string();
assert!(
err.contains("no identity to verify against"),
"{project}: {err}"
);
}
}
}
#[cfg(all(test, feature = "install-cli"))]
mod install_tests {
use std::collections::BTreeMap;
use std::path::PathBuf;
use packslip::install::Report;
use packslip::install_fs::{Entry, History, Receipt};
fn printed(pin: Option<&str>) -> (String, String) {
let tree = PathBuf::from("/installs/hk");
let command = PathBuf::from("/bin/hk");
let report = Report {
project: "github.com/jdx/hk".into(),
receipt: Receipt {
schema: 1,
project: "github.com/jdx/hk".into(),
version: "1.2.3".into(),
tree: tree.clone(),
exports: BTreeMap::from([(command, Entry::Link(tree.join("hk")))]),
history: History::default(),
},
pin: pin.map(Into::into),
warnings: vec!["/bin is absent from PATH".into()],
};
let (mut out, mut err) = (Vec::new(), Vec::new());
super::print_install_report(report, &mut out, &mut err).unwrap();
(
String::from_utf8(out).unwrap(),
String::from_utf8(err).unwrap(),
)
}
#[test]
fn install_prints_the_signer_fingerprint_of_a_keyless_release() {
let (out, err) = printed(Some("ps1_snirenkjwr7m5ozgcufameodnm"));
assert_eq!(
out,
"Installed github.com/jdx/hk 1.2.3 at /installs/hk\n\
Signer fingerprint: ps1_snirenkjwr7m5ozgcufameodnm\n\
Command: /bin/hk\n"
);
assert_eq!(err, "warning: /bin is absent from PATH\n");
let (out, _) = printed(None);
assert_eq!(
out,
"Installed github.com/jdx/hk 1.2.3 at /installs/hk\nCommand: /bin/hk\n"
);
}
}