use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use sha2::{Digest, Sha256};
use tirith_core::selfupdate::{self, InstallMethod, Provenance, SemVer, VerificationStatus};
const REPO: &str = "sheeki03/tirith";
const API_TIMEOUT_SECS: u64 = 20;
const DOWNLOAD_TIMEOUT_SECS: u64 = 120;
const MAX_ARCHIVE_SIZE: u64 = 64 * 1024 * 1024;
const MAX_METADATA_SIZE: u64 = 256 * 1024;
const COSIGN_IDENTITY_REGEXP: &str = "github.com/sheeki03/tirith";
const COSIGN_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";
pub fn gather_provenance() -> Provenance {
let raw_exe = std::env::current_exe().ok();
let resolved = raw_exe
.as_deref()
.and_then(crate::cli::resolve_effective_tirith_target);
let path_resolution_failed = resolved.is_none() && raw_exe.is_some();
let binary_path = resolved.or(raw_exe);
let binary_sha256 = binary_path.as_deref().and_then(hash_file_opt);
let install_method = match &binary_path {
Some(p) => {
let m = selfupdate::detect_install_method(p);
selfupdate::refine_system_pm(m, &read_os_release_ids())
}
None => InstallMethod::Unknown,
};
let dev_build =
selfupdate::looks_like_dev_build(binary_path.as_deref(), cfg!(debug_assertions));
Provenance {
version: env!("CARGO_PKG_VERSION").to_string(),
binary_path,
binary_sha256,
target: selfupdate::release_target_triple().map(|s| s.to_string()),
install_method,
dev_build,
path_resolution_failed,
}
}
pub fn version(provenance: bool, json: bool) -> i32 {
if !provenance {
if json {
let v = serde_json::json!({ "version": env!("CARGO_PKG_VERSION") });
println!("{v}");
} else {
println!("tirith {}", env!("CARGO_PKG_VERSION"));
}
return 0;
}
let prov = gather_provenance();
let local_status = local_verification_status(&prov);
if json {
let v = serde_json::json!({
"version": prov.version,
"binary_path": prov.binary_path.as_ref().map(|p| p.display().to_string()),
"binary_sha256": prov.binary_sha256,
"target": prov.target,
"install_method": prov.install_method.as_str(),
"install_method_resolved": !prov.path_resolution_failed,
"dev_build": prov.dev_build,
"build_profile": if cfg!(debug_assertions) { "debug" } else { "release" },
"verification_status": local_status.token(),
"verification_detail": status_detail(&local_status),
});
match serde_json::to_string_pretty(&v) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("tirith: JSON serialization failed: {e}");
return 1;
}
}
} else {
println!("tirith {}", prov.version);
println!(
" build profile: {}",
if cfg!(debug_assertions) {
"debug"
} else {
"release"
}
);
println!(
" target: {}",
prov.target.as_deref().unwrap_or("(unpublished platform)")
);
println!(
" binary: {}",
prov.binary_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string())
);
if let Some(sha) = &prov.binary_sha256 {
println!(" sha256: {sha}");
}
println!(" install method: {}", prov.install_method.as_str());
if prov.path_resolution_failed {
println!(
" note: the binary path could not be fully resolved; the install \
method above is a lower-confidence guess"
);
}
println!(" verification: {}", describe_status(&local_status));
if prov.dev_build {
println!(
" note: this is a local/dev build — run `tirith verify-self` \
against a release to verify provenance"
);
} else {
println!(" note: run `tirith verify-self` for full networked provenance verification");
}
}
0
}
fn read_os_release_ids() -> Vec<String> {
if !cfg!(target_os = "linux") {
return Vec::new();
}
let contents = match std::fs::read_to_string("/etc/os-release") {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut ids = Vec::new();
for line in contents.lines() {
let line = line.trim();
let (key, value) = match line.split_once('=') {
Some(kv) => kv,
None => continue,
};
if key != "ID" && key != "ID_LIKE" {
continue;
}
let value = value.trim().trim_matches(['"', '\'']);
for tok in value.split_whitespace() {
let tok = tok.to_lowercase();
if !tok.is_empty() && !ids.contains(&tok) {
ids.push(tok);
}
}
}
ids
}
fn local_verification_status(prov: &Provenance) -> VerificationStatus {
if prov.dev_build {
return VerificationStatus::Unverified {
reason: "local/dev build — not an installed release, cannot verify against a \
release checksum"
.to_string(),
};
}
if prov.target.is_none() {
return VerificationStatus::Unverified {
reason: "this platform has no published tirith release artifact".to_string(),
};
}
if SemVer::parse(&prov.version).is_none() {
return VerificationStatus::Unverified {
reason: format!(
"version `{}` is not a parseable release version",
prov.version
),
};
}
VerificationStatus::Unverified {
reason: "offline check — run `tirith verify-self` to verify against the signed release"
.to_string(),
}
}
struct VerifySelfOutcome {
status: VerificationStatus,
operational_error: bool,
detail_override: Option<String>,
}
impl VerifySelfOutcome {
fn verdict(status: VerificationStatus) -> Self {
VerifySelfOutcome {
status,
operational_error: false,
detail_override: None,
}
}
fn operational(reason: String) -> Self {
VerifySelfOutcome {
status: VerificationStatus::Unverified { reason },
operational_error: true,
detail_override: None,
}
}
fn with_detail(mut self, detail: Option<String>) -> Self {
self.detail_override = detail;
self
}
}
pub fn verify_self(json: bool) -> i32 {
let prov = gather_provenance();
let outcome = run_verify_self(&prov);
let emit_rc = emit_verify_self(
&prov,
&outcome.status,
outcome.detail_override.as_deref(),
json,
);
let verdict_rc = match outcome.status {
VerificationStatus::Failed { .. } => 1,
_ if outcome.operational_error => 1,
_ => 0,
};
verdict_rc.max(emit_rc)
}
fn source_built_unverified_reason(method: &InstallMethod) -> Option<String> {
match method {
InstallMethod::Cargo => Some(
"installed via `cargo install` (compiled from source) — there is no canonical \
release binary to byte-compare against"
.to_string(),
),
InstallMethod::Aur => Some(
"installed from the AUR (compiled from source by your AUR helper) — there is no \
canonical release binary to byte-compare against"
.to_string(),
),
InstallMethod::Dnf => Some(
"installed from the distribution .rpm, which is built against the target distro's \
glibc and is intentionally not byte-identical to the generic Linux release binary"
.to_string(),
),
InstallMethod::SelfManaged
| InstallMethod::Homebrew
| InstallMethod::Npm
| InstallMethod::Scoop
| InstallMethod::Apt
| InstallMethod::Unknown => None,
}
}
fn benign_mismatch_reason(method: &InstallMethod) -> Option<String> {
match method {
InstallMethod::Homebrew => Some(
"installed via Homebrew; the homebrew-core formula builds tirith from source \
(distributed as a bottle), so the binary is not byte-identical to the prebuilt \
release artifact. `brew install sheeki03/tap/tirith` pours the prebuilt, signed \
binary that verifies."
.to_string(),
),
InstallMethod::SelfManaged
| InstallMethod::Npm
| InstallMethod::Scoop
| InstallMethod::Apt
| InstallMethod::Cargo
| InstallMethod::Aur
| InstallMethod::Dnf
| InstallMethod::Unknown => None,
}
}
fn run_verify_self(prov: &Provenance) -> VerifySelfOutcome {
if prov.dev_build {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified {
reason: "this is a local/dev build (compiled from source, not installed from a \
release) — there is no release checksum to verify it against"
.to_string(),
});
}
if let Some(reason) = source_built_unverified_reason(&prov.install_method) {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified { reason });
}
let target = match &prov.target {
Some(t) => t.clone(),
None => {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified {
reason: "this platform has no published tirith release artifact to verify \
against"
.to_string(),
})
}
};
let version = match SemVer::parse(&prov.version) {
Some(v) => v,
None => {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified {
reason: format!(
"running version `{}` is not a parseable release version",
prov.version
),
})
}
};
let (binary_path, binary_sha) = match (&prov.binary_path, &prov.binary_sha256) {
(Some(p), Some(s)) => (p.clone(), s.clone()),
(Some(p), None) => {
return VerifySelfOutcome::operational(format!(
"could not read the running binary's own bytes at {} (I/O or permission error, \
or the binary was replaced) — cannot verify",
p.display()
))
}
(None, _) => {
return VerifySelfOutcome::operational(
"could not determine the running binary's own path — cannot verify".to_string(),
)
}
};
let tag = format!("v{version}");
let archive_name = selfupdate::release_archive_name(&target);
let workdir = match tempfile::Builder::new().prefix("tirith-verify-").tempdir() {
Ok(d) => d,
Err(e) => {
return VerifySelfOutcome::operational(format!(
"could not create a working directory: {e}"
));
}
};
let release = match download_release_set(&tag, &archive_name, workdir.path()) {
Ok(r) => r,
Err(DownloadError::Offline(msg)) => {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified {
reason: format!("could not reach the release server ({msg}) — re-run online"),
})
}
Err(DownloadError::NotFound(msg)) => {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified {
reason: format!(
"no release artifact found for {tag} ({msg}) — this binary may predate \
the release-checksum scheme, or be a custom build"
),
})
}
Err(DownloadError::Other(msg)) => {
return VerifySelfOutcome::verdict(VerificationStatus::Failed {
reason: format!("release download failed: {msg}"),
})
}
};
let verdict = verify_archive_against_checksums(&release, &archive_name);
let (checksum_status, cosign_note) = match verdict {
ArchiveVerdict::Ok {
signed,
cosign_note,
} => (signed, cosign_note),
ArchiveVerdict::Failed(reason) => {
return VerifySelfOutcome::verdict(VerificationStatus::Failed { reason })
}
ArchiveVerdict::ChecksumMissing(reason) => {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified { reason })
}
};
let extracted = match extract_tirith_binary(&release.archive_path, &target, workdir.path()) {
Ok(p) => p,
Err(e) => {
return VerifySelfOutcome::verdict(VerificationStatus::Failed {
reason: format!(
"could not extract the tirith binary from the verified release archive: {e}"
),
})
}
};
let extracted_sha = match hash_file_opt(&extracted) {
Some(s) => s,
None => {
return VerifySelfOutcome::verdict(VerificationStatus::Failed {
reason: "could not hash the binary extracted from the release archive".to_string(),
})
}
};
if !selfupdate::digest_eq(&extracted_sha, &binary_sha) {
if let Some(reason) = benign_mismatch_reason(&prov.install_method) {
return VerifySelfOutcome::verdict(VerificationStatus::Unverified { reason });
}
return VerifySelfOutcome::verdict(VerificationStatus::Failed {
reason: format!(
"the running binary at {} (sha256 {}) does NOT match the official {} release \
binary (sha256 {}) — it has been modified or replaced",
binary_path.display(),
short(&binary_sha),
tag,
short(&extracted_sha),
),
});
}
match checksum_status {
ChecksumStrength::Signed => VerifySelfOutcome::verdict(VerificationStatus::VerifiedSigned),
ChecksumStrength::ChecksumOnly => {
VerifySelfOutcome::verdict(VerificationStatus::VerifiedChecksumOnly)
.with_detail(cosign_note)
}
}
}
fn emit_verify_self(
prov: &Provenance,
status: &VerificationStatus,
detail_override: Option<&str>,
json: bool,
) -> i32 {
let detail = detail_override.map_or_else(|| status_detail(status), |d| d.to_string());
if json {
let v = serde_json::json!({
"version": prov.version,
"binary_path": prov.binary_path.as_ref().map(|p| p.display().to_string()),
"binary_sha256": prov.binary_sha256,
"install_method": prov.install_method.as_str(),
"install_method_resolved": !prov.path_resolution_failed,
"target": prov.target,
"dev_build": prov.dev_build,
"verification_status": status.token(),
"verification_detail": detail,
"integrity_ok": status.is_integrity_ok(),
});
match serde_json::to_string_pretty(&v) {
Ok(s) => {
println!("{s}");
return 0;
}
Err(e) => {
eprintln!("tirith: JSON serialization failed: {e}");
return 1;
}
}
}
println!("tirith verify-self");
println!(" version: {}", prov.version);
println!(
" binary: {}",
prov.binary_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string())
);
println!(" install method: {}", prov.install_method.as_str());
if prov.path_resolution_failed {
println!(
" note: the binary path could not be fully resolved; the install method \
above is a lower-confidence guess"
);
}
println!();
match status {
VerificationStatus::VerifiedSigned => {
println!(" VERIFIED (signed)");
println!(
" The running binary matches the official signed release: its SHA-256 is in \
the release checksums.txt and the cosign signature over checksums.txt verified."
);
}
VerificationStatus::VerifiedChecksumOnly => {
println!(" VERIFIED (checksum only)");
println!(
" The running binary matches the SHA-256 published in the release \
checksums.txt."
);
if detail_override.is_some() {
println!(" The cosign signature was NOT checked: {detail}");
} else {
println!(
" The cosign signature was NOT checked (cosign is not installed). Install \
cosign and re-run for full signature verification."
);
}
}
VerificationStatus::Unverified { reason } => {
println!(" UNVERIFIED (could not verify — this is not a failure)");
println!(" {reason}");
}
VerificationStatus::Failed { reason } => {
println!(" FAILED — the running binary did NOT verify");
println!(" {reason}");
println!();
println!(
" Do not trust this binary. Re-install tirith from a trusted source \
(https://github.com/{REPO})."
);
}
}
0
}
pub fn update(allow_unsigned: bool, rollback: bool, dry_run: bool, yes: bool, json: bool) -> i32 {
let prov = gather_provenance();
if rollback {
return run_rollback(&prov, dry_run, yes, json);
}
run_update(&prov, allow_unsigned, dry_run, yes, json)
}
fn run_update(
prov: &Provenance,
allow_unsigned: bool,
dry_run: bool,
yes: bool,
json: bool,
) -> i32 {
if !prov.install_method.is_self_replaceable() {
return advise_package_manager(prov, json);
}
let current = match SemVer::parse(&prov.version) {
Some(v) => v,
None => {
emit_update_error(
json,
&format!(
"running version `{}` is not a parseable release version; cannot \
determine whether an update is needed",
prov.version
),
);
return 1;
}
};
let target = match &prov.target {
Some(t) => t.clone(),
None => {
emit_update_error(
json,
"this platform has no published tirith release artifact to update from",
);
return 1;
}
};
let binary_path = match &prov.binary_path {
Some(p) => p.clone(),
None => {
emit_update_error(json, "could not resolve the running binary's path");
return 1;
}
};
let latest = match fetch_latest_version() {
Ok(v) => v,
Err(DownloadError::Offline(msg)) => {
emit_update_error(
json,
&format!("could not reach the release server ({msg}) — re-run online"),
);
return 1;
}
Err(e) => {
emit_update_error(
json,
&format!("could not determine the latest release: {e}"),
);
return 1;
}
};
if latest <= current {
if json {
let v = serde_json::json!({
"action": "none",
"current_version": current.to_string(),
"latest_version": latest.to_string(),
"message": "already up to date",
});
println!("{v}");
} else {
println!("tirith is already up to date (v{current}; latest release is v{latest}).");
}
return 0;
}
if dry_run {
if json {
let v = serde_json::json!({
"action": "would-update",
"current_version": current.to_string(),
"latest_version": latest.to_string(),
"install_method": prov.install_method.as_str(),
"binary_path": binary_path.display().to_string(),
"allow_unsigned": allow_unsigned,
});
println!("{v}");
} else {
println!("tirith update (dry run)");
println!(" current: v{current}");
println!(" latest: v{latest}");
println!(" binary: {}", binary_path.display());
println!(
" would download, {}, and atomically replace the binary in place.",
if allow_unsigned {
"verify the checksum (cosign signature optional)"
} else {
"verify the checksum and cosign signature"
}
);
}
return 0;
}
if !crate::cli::confirm(&format!("Update tirith from v{current} to v{latest}?"), yes) {
eprintln!("tirith: update cancelled");
return 0;
}
let tag = format!("v{latest}");
let archive_name = selfupdate::release_archive_name(&target);
let workdir = match tempfile::Builder::new().prefix("tirith-update-").tempdir() {
Ok(d) => d,
Err(e) => {
emit_update_error(json, &format!("could not create a working directory: {e}"));
return 1;
}
};
println!("tirith: downloading {tag} for {target}...");
let release = match download_release_set(&tag, &archive_name, workdir.path()) {
Ok(r) => r,
Err(e) => {
emit_update_error(json, &format!("download failed: {}", e.message()));
return 1;
}
};
let archive_verdict = verify_archive_against_checksums(&release, &archive_name);
match &archive_verdict {
ArchiveVerdict::Failed(reason) => {
emit_update_error(
json,
&format!("release verification FAILED — aborting update: {reason}"),
);
return 1;
}
ArchiveVerdict::ChecksumMissing(reason) => {
emit_update_error(
json,
&format!(
"release checksum could not be verified ({reason}) — aborting update; \
install manually from https://github.com/{REPO}/releases if intended"
),
);
return 1;
}
ArchiveVerdict::Ok {
signed,
cosign_note,
} => {
if !allow_unsigned && *signed == ChecksumStrength::ChecksumOnly {
let why = cosign_note.as_deref().unwrap_or(
"the cosign signature could not be verified (cosign is not installed, or \
this release did not publish a signature)",
);
emit_update_error(
json,
&format!(
"release signature verification is required but could not be completed: \
{why}. The release checksum DID verify. Install cosign and re-run, or \
pass --allow-unsigned to update with checksum-only verification (NOT \
recommended)."
),
);
return 1;
}
}
}
let new_binary = match extract_tirith_binary(&release.archive_path, &target, workdir.path()) {
Ok(p) => p,
Err(e) => {
emit_update_error(
json,
&format!("could not extract the tirith binary from the release archive: {e}"),
);
return 1;
}
};
let swap = match atomic_self_replace(&binary_path, &new_binary) {
Ok(s) => s,
Err(e) => {
emit_update_error(json, &format!("could not install the new binary: {e}"));
return 1;
}
};
if json {
let v = serde_json::json!({
"action": "updated",
"previous_version": current.to_string(),
"new_version": latest.to_string(),
"binary_path": binary_path.display().to_string(),
"previous_binary_kept_at": swap.previous_backup.display().to_string(),
"verification": match &archive_verdict {
ArchiveVerdict::Ok { signed: ChecksumStrength::Signed, .. } => "verified-signed",
ArchiveVerdict::Ok { signed: ChecksumStrength::ChecksumOnly, .. } => {
"verified-checksum-only"
}
_ => "unverified",
},
});
println!("{v}");
} else {
println!();
println!("tirith updated: v{current} -> v{latest}");
println!(" binary: {}", binary_path.display());
println!(
" verification: {}",
match &archive_verdict {
ArchiveVerdict::Ok {
signed: ChecksumStrength::Signed,
..
} => "signed release (checksum + cosign signature)",
ArchiveVerdict::Ok {
signed: ChecksumStrength::ChecksumOnly,
..
} => "checksum-verified (cosign not installed — signature unchecked)",
_ => "unverified",
}
);
println!(
" previous: kept at {} — run `tirith update --rollback` to revert",
swap.previous_backup.display()
);
}
0
}
fn advise_package_manager(prov: &Provenance, json: bool) -> i32 {
let method = &prov.install_method;
let cmd = method.upgrade_command();
if json {
let v = serde_json::json!({
"action": "use-package-manager",
"install_method": method.as_str(),
"current_version": prov.version,
"upgrade_command": cmd,
"message": match method {
InstallMethod::Unknown => "tirith could not determine how it was installed; \
it will not self-modify the binary. Update it the same way you installed it.",
_ => "tirith was installed by a package manager; update it with the package \
manager so its database stays consistent.",
},
});
match serde_json::to_string_pretty(&v) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("tirith: JSON serialization failed: {e}");
return 1;
}
}
return 0;
}
match method {
InstallMethod::Unknown => {
println!(
"tirith could not determine how it was installed (binary: {}).",
prov.binary_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string())
);
println!(
"It will NOT self-modify the binary. Update tirith the same way you installed \
it, or re-install from https://github.com/{REPO}."
);
}
_ => {
println!(
"tirith was installed via {} — it will not self-modify a package-managed \
install.",
method.as_str()
);
if let Some(c) = cmd {
println!();
println!("To update, run:");
println!(" {c}");
}
}
}
0
}
fn run_rollback(prov: &Provenance, dry_run: bool, yes: bool, json: bool) -> i32 {
if !prov.install_method.is_self_replaceable() {
let msg = format!(
"--rollback only applies to a self-managed (install.sh / standalone) install; \
this is a `{}` install. Use the package manager to install a previous version.",
prov.install_method.as_str()
);
if json {
let v = serde_json::json!({
"action": "rollback-unavailable",
"install_method": prov.install_method.as_str(),
"message": msg,
});
println!("{v}");
} else {
println!("tirith: {msg}");
}
return 1;
}
let binary_path = match &prov.binary_path {
Some(p) => p.clone(),
None => {
emit_update_error(json, "could not resolve the running binary's path");
return 1;
}
};
let backup = previous_backup_path(&binary_path);
if !backup.is_file() {
let msg = format!(
"no previous binary to roll back to (expected {}). A rollback point is only \
created by `tirith update`.",
backup.display()
);
if json {
let v = serde_json::json!({
"action": "rollback-unavailable",
"message": msg,
});
println!("{v}");
} else {
println!("tirith: {msg}");
}
return 1;
}
if dry_run {
if json {
let v = serde_json::json!({
"action": "would-rollback",
"binary_path": binary_path.display().to_string(),
"rollback_from": backup.display().to_string(),
});
println!("{v}");
} else {
println!("tirith update --rollback (dry run)");
println!(
" would restore {} from {}",
binary_path.display(),
backup.display()
);
}
return 0;
}
if !crate::cli::confirm("Roll tirith back to the previously-installed binary?", yes) {
eprintln!("tirith: rollback cancelled");
return 0;
}
match atomic_restore_from(&binary_path, &backup) {
Ok(()) => {
if let Err(e) = std::fs::remove_file(&backup) {
eprintln!(
"tirith: warning: rolled back successfully but could not remove the now-stale \
backup {} ({e}); delete it manually — a future `--rollback` would otherwise \
restore these same (no-longer-previous) bytes",
backup.display()
);
}
if json {
let v = serde_json::json!({
"action": "rolled-back",
"binary_path": binary_path.display().to_string(),
});
println!("{v}");
} else {
println!("tirith: rolled back to the previously-installed binary.");
println!(" binary: {}", binary_path.display());
println!(" run `tirith version` to confirm the version.");
}
0
}
Err(e) => {
emit_update_error(json, &format!("rollback failed: {e}"));
1
}
}
}
fn emit_update_error(json: bool, msg: &str) {
if json {
let v = serde_json::json!({ "action": "error", "error": msg });
println!("{v}");
} else {
eprintln!("tirith: {msg}");
}
}
struct ReleaseSet {
archive_path: PathBuf,
checksums_txt: String,
sig_path: Option<PathBuf>,
cert_path: Option<PathBuf>,
checksums_path: PathBuf,
}
enum DownloadError {
Offline(String),
NotFound(String),
Other(String),
}
impl DownloadError {
fn message(&self) -> String {
match self {
DownloadError::Offline(m) => format!("offline: {m}"),
DownloadError::NotFound(m) => format!("not found: {m}"),
DownloadError::Other(m) => m.clone(),
}
}
}
impl std::fmt::Display for DownloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message())
}
}
fn download_release_set(
tag: &str,
archive_name: &str,
workdir: &Path,
) -> Result<ReleaseSet, DownloadError> {
let base = format!("https://github.com/{REPO}/releases/download/{tag}");
let client = http_client(DOWNLOAD_TIMEOUT_SECS)
.map_err(|e| DownloadError::Other(format!("HTTP client: {e}")))?;
let archive_url = format!("{base}/{archive_name}");
let archive_bytes = fetch_bytes(&client, &archive_url, MAX_ARCHIVE_SIZE)?;
let archive_path = workdir.join(archive_name);
write_file(&archive_path, &archive_bytes)
.map_err(|e| DownloadError::Other(format!("write archive: {e}")))?;
let checksums_url = format!("{base}/checksums.txt");
let checksums_bytes = fetch_bytes(&client, &checksums_url, MAX_METADATA_SIZE)?;
let checksums_txt = String::from_utf8(checksums_bytes.clone())
.map_err(|_| DownloadError::Other("checksums.txt is not valid UTF-8".to_string()))?;
let checksums_path = workdir.join("checksums.txt");
write_file(&checksums_path, &checksums_bytes)
.map_err(|e| DownloadError::Other(format!("write checksums.txt: {e}")))?;
let sig_path = fetch_optional(
&client,
&format!("{base}/checksums.txt.sig"),
workdir,
"checksums.txt.sig",
);
let cert_path = fetch_optional(
&client,
&format!("{base}/checksums.txt.pem"),
workdir,
"checksums.txt.pem",
);
Ok(ReleaseSet {
archive_path,
checksums_txt,
sig_path,
cert_path,
checksums_path,
})
}
fn fetch_latest_version() -> Result<SemVer, DownloadError> {
let client = http_client(API_TIMEOUT_SECS)
.map_err(|e| DownloadError::Other(format!("HTTP client: {e}")))?;
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let body = fetch_bytes(&client, &url, MAX_METADATA_SIZE)?;
let json: serde_json::Value = serde_json::from_slice(&body)
.map_err(|e| DownloadError::Other(format!("GitHub API response was not JSON: {e}")))?;
let tag = json
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| DownloadError::Other("GitHub API response had no tag_name".to_string()))?;
SemVer::parse(tag).ok_or_else(|| {
DownloadError::Other(format!(
"latest release tag `{tag}` is not a parseable version"
))
})
}
fn http_client(timeout_secs: u64) -> Result<reqwest::blocking::Client, reqwest::Error> {
reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
}
fn fetch_bytes(
client: &reqwest::blocking::Client,
url: &str,
max: u64,
) -> Result<Vec<u8>, DownloadError> {
let resp = client
.get(url)
.header(
"User-Agent",
format!("tirith/{} (self-update)", env!("CARGO_PKG_VERSION")),
)
.send()
.map_err(|e| {
if e.is_connect() || e.is_timeout() {
DownloadError::Offline(e.to_string())
} else {
DownloadError::Other(e.to_string())
}
})?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(DownloadError::NotFound(format!("{url} returned 404")));
}
if !status.is_success() {
return Err(DownloadError::Other(format!(
"{url} returned HTTP {status}"
)));
}
if let Some(len) = resp.content_length() {
if len > max {
return Err(DownloadError::Other(format!(
"{url} body is {len} bytes (max {max})"
)));
}
}
use std::io::Read as _;
let mut buf = Vec::new();
resp.take(max + 1)
.read_to_end(&mut buf)
.map_err(|e| DownloadError::Other(format!("reading {url}: {e}")))?;
if buf.len() as u64 > max {
return Err(DownloadError::Other(format!(
"{url} body exceeds the {max}-byte limit"
)));
}
Ok(buf)
}
fn fetch_optional(
client: &reqwest::blocking::Client,
url: &str,
workdir: &Path,
name: &str,
) -> Option<PathBuf> {
let bytes = fetch_bytes(client, url, MAX_METADATA_SIZE).ok()?;
let path = workdir.join(name);
write_file(&path, &bytes).ok()?;
Some(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChecksumStrength {
Signed,
ChecksumOnly,
}
enum ArchiveVerdict {
Ok {
signed: ChecksumStrength,
cosign_note: Option<String>,
},
Failed(String),
ChecksumMissing(String),
}
fn verify_archive_against_checksums(release: &ReleaseSet, archive_name: &str) -> ArchiveVerdict {
let archive_bytes = match std::fs::read(&release.archive_path) {
Ok(b) => b,
Err(e) => {
return ArchiveVerdict::Failed(format!("could not re-read the downloaded archive: {e}"))
}
};
let archive_sha = hex_sha256(&archive_bytes);
let expected = match selfupdate::checksum_for(&release.checksums_txt, archive_name) {
Ok(Some(d)) => d,
Ok(None) => {
return ArchiveVerdict::ChecksumMissing(format!(
"checksums.txt has no entry for {archive_name}"
))
}
Err(e) => return ArchiveVerdict::Failed(format!("checksums.txt is malformed: {e}")),
};
if !selfupdate::digest_eq(&archive_sha, &expected) {
return ArchiveVerdict::Failed(format!(
"archive SHA-256 mismatch: downloaded {} but checksums.txt expects {}",
short(&archive_sha),
short(&expected),
));
}
match verify_cosign_signature(release) {
CosignOutcomeInternal::Verified => ArchiveVerdict::Ok {
signed: ChecksumStrength::Signed,
cosign_note: None,
},
CosignOutcomeInternal::Unavailable(reason) => ArchiveVerdict::Ok {
signed: ChecksumStrength::ChecksumOnly,
cosign_note: Some(reason.detail()),
},
CosignOutcomeInternal::Failed(reason) => {
ArchiveVerdict::Failed(format!("cosign signature verification FAILED: {reason}"))
}
}
}
enum CosignUnavailable {
NoSignaturePublished,
NotInstalled,
ExecFailed(String),
}
impl CosignUnavailable {
fn detail(&self) -> String {
match self {
CosignUnavailable::NoSignaturePublished => {
"this release did not publish a cosign signature, so only the checksum was \
verified"
.to_string()
}
CosignUnavailable::NotInstalled => {
"cosign is not installed, so the signature was not checked — install cosign and \
re-run for full signature verification"
.to_string()
}
CosignUnavailable::ExecFailed(err) => format!(
"cosign is installed but could not be executed ({err}), so the signature was \
not checked — verify the cosign installation"
),
}
}
}
enum CosignOutcomeInternal {
Verified,
Unavailable(CosignUnavailable),
Failed(String),
}
fn verify_cosign_signature(release: &ReleaseSet) -> CosignOutcomeInternal {
let (sig, cert) = match (&release.sig_path, &release.cert_path) {
(Some(s), Some(c)) => (s, c),
_ => return CosignOutcomeInternal::Unavailable(CosignUnavailable::NoSignaturePublished),
};
if !cosign_available() {
return CosignOutcomeInternal::Unavailable(CosignUnavailable::NotInstalled);
}
let output = std::process::Command::new("cosign")
.arg("verify-blob")
.arg("--signature")
.arg(sig)
.arg("--certificate")
.arg(cert)
.arg("--certificate-identity-regexp")
.arg(COSIGN_IDENTITY_REGEXP)
.arg("--certificate-oidc-issuer")
.arg(COSIGN_OIDC_ISSUER)
.arg(&release.checksums_path)
.output();
match output {
Ok(out) if out.status.success() => CosignOutcomeInternal::Verified,
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
CosignOutcomeInternal::Failed(
stderr
.lines()
.next()
.unwrap_or("cosign verify-blob exited non-zero")
.to_string(),
)
}
Err(e) => {
eprintln!("tirith: warning: could not run cosign ({e}); skipping signature check");
CosignOutcomeInternal::Unavailable(CosignUnavailable::ExecFailed(e.to_string()))
}
}
}
fn cosign_available() -> bool {
let probe = {
#[cfg(unix)]
{
std::process::Command::new("sh")
.args(["-c", "command -v cosign >/dev/null 2>&1"])
.status()
}
#[cfg(not(unix))]
{
std::process::Command::new("where.exe")
.arg("cosign")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
}
};
probe.map(|s| s.success()).unwrap_or(false)
}
fn extract_tirith_binary(archive: &Path, target: &str, workdir: &Path) -> Result<PathBuf, String> {
let extract_dir = workdir.join("extracted");
std::fs::create_dir_all(&extract_dir).map_err(|e| format!("create extract dir: {e}"))?;
let binary_name = if target.contains("windows") {
"tirith.exe"
} else {
"tirith"
};
if target.contains("windows") {
let status = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command"])
.arg(format!(
"Expand-Archive -LiteralPath '{}' -DestinationPath '{}' -Force",
archive.display(),
extract_dir.display(),
))
.status()
.map_err(|e| format!("could not run PowerShell Expand-Archive: {e}"))?;
if !status.success() {
return Err("Expand-Archive failed to extract the release zip".to_string());
}
} else {
let status = std::process::Command::new("tar")
.arg("--no-same-owner")
.arg("-xzf")
.arg(archive)
.arg("-C")
.arg(&extract_dir)
.status()
.map_err(|e| format!("could not run tar: {e}"))?;
if !status.success() {
return Err("tar failed to extract the release archive".to_string());
}
}
let binary = extract_dir.join(binary_name);
if !binary.is_file() {
return Err(format!(
"release archive did not contain a `{binary_name}` binary"
));
}
let canonical_extract_dir = extract_dir
.canonicalize()
.map_err(|e| format!("could not canonicalize the extraction directory: {e}"))?;
let canonical_binary = binary
.canonicalize()
.map_err(|e| format!("could not canonicalize the extracted `{binary_name}` path: {e}"))?;
if !canonical_binary.starts_with(&canonical_extract_dir) {
return Err(format!(
"the extracted `{binary_name}` resolves to {} which is OUTSIDE the extraction \
directory {} — refusing to use it (the release archive may contain a \
path-traversal payload)",
canonical_binary.display(),
canonical_extract_dir.display(),
));
}
Ok(canonical_binary)
}
struct SwapResult {
previous_backup: PathBuf,
}
fn previous_backup_path(binary_path: &Path) -> PathBuf {
let mut p = binary_path.to_path_buf();
let name = binary_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "tirith".to_string());
p.set_file_name(format!("{name}.tirith-previous"));
p
}
fn atomic_self_replace(dest: &Path, new_binary: &Path) -> Result<SwapResult, String> {
let dir = dest
.parent()
.ok_or_else(|| "cannot determine the binary's directory".to_string())?;
if !dir_is_writable(dir) {
return Err(format!(
"the directory {} is not writable — tirith cannot replace its own binary there \
(is this a system path that needs sudo, or a package-managed install?)",
dir.display()
));
}
let backup = previous_backup_path(dest);
std::fs::copy(dest, &backup).map_err(|e| {
format!(
"could not save the current binary to {}: {e}",
backup.display()
)
})?;
std::fs::OpenOptions::new()
.write(true)
.open(&backup)
.and_then(|f| f.sync_all())
.map_err(|e| {
format!(
"could not sync the rollback backup {}: {e}",
backup.display()
)
})?;
let mut tmp = tempfile::Builder::new()
.prefix(".tirith-new-")
.tempfile_in(dir)
.map_err(|e| format!("could not create a temp file in {}: {e}", dir.display()))?;
let new_bytes =
std::fs::read(new_binary).map_err(|e| format!("could not read the new binary: {e}"))?;
tmp.write_all(&new_bytes)
.map_err(|e| format!("could not write the new binary: {e}"))?;
tmp.flush()
.map_err(|e| format!("could not flush the new binary: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("could not set executable permissions: {e}"))?;
}
tmp.as_file()
.sync_all()
.map_err(|e| format!("could not sync the new binary: {e}"))?;
tmp.persist(dest).map_err(|e| {
format!(
"could not atomically replace {}: {} (the old binary is intact)",
dest.display(),
e.error
)
})?;
fsync_parent_dir(dest);
Ok(SwapResult {
previous_backup: backup,
})
}
fn atomic_restore_from(dest: &Path, source: &Path) -> Result<(), String> {
let dir = dest
.parent()
.ok_or_else(|| "cannot determine the binary's directory".to_string())?;
if !dir_is_writable(dir) {
return Err(format!(
"the directory {} is not writable — tirith cannot restore its binary there",
dir.display()
));
}
let bytes = std::fs::read(source).map_err(|e| {
format!(
"could not read the rollback binary {}: {e}",
source.display()
)
})?;
let mut tmp = tempfile::Builder::new()
.prefix(".tirith-rollback-")
.tempfile_in(dir)
.map_err(|e| format!("could not create a temp file in {}: {e}", dir.display()))?;
tmp.write_all(&bytes)
.map_err(|e| format!("could not write the rollback binary: {e}"))?;
tmp.flush()
.map_err(|e| format!("could not flush the rollback binary: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("could not set executable permissions: {e}"))?;
}
tmp.as_file()
.sync_all()
.map_err(|e| format!("could not sync the rollback binary: {e}"))?;
tmp.persist(dest).map_err(|e| {
format!(
"could not atomically restore {}: {} (the current binary is intact)",
dest.display(),
e.error
)
})?;
fsync_parent_dir(dest);
Ok(())
}
fn fsync_parent_dir(path: &Path) {
tirith_core::util::fsync_parent_dir_logged(path, "binary swap");
}
fn dir_is_writable(dir: &Path) -> bool {
tempfile::Builder::new()
.prefix(".tirith-wtest-")
.tempfile_in(dir)
.is_ok()
}
fn hex_sha256(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
format!("{:x}", h.finalize())
}
fn hash_file_opt(path: &Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
Some(hex_sha256(&bytes))
}
fn write_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.flush()
}
fn short(digest: &str) -> String {
digest.chars().take(12).collect()
}
fn status_detail(status: &VerificationStatus) -> String {
match status {
VerificationStatus::VerifiedSigned => {
"binary matches the signed release (checksum + cosign signature verified)".to_string()
}
VerificationStatus::VerifiedChecksumOnly => {
"binary matches the release checksum; cosign signature not checked".to_string()
}
VerificationStatus::Unverified { reason } | VerificationStatus::Failed { reason } => {
reason.clone()
}
}
}
fn describe_status(status: &VerificationStatus) -> String {
match status {
VerificationStatus::VerifiedSigned => "verified (signed release)".to_string(),
VerificationStatus::VerifiedChecksumOnly => "verified (checksum only)".to_string(),
VerificationStatus::Unverified { reason } => format!("unverified — {reason}"),
VerificationStatus::Failed { reason } => format!("FAILED — {reason}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atomic_self_replace_swaps_and_keeps_backup() {
let dir = tempfile::tempdir().unwrap();
let live = dir.path().join("tirith");
let new = dir.path().join("new-tirith");
std::fs::write(&live, b"OLD-BINARY").unwrap();
std::fs::write(&new, b"NEW-BINARY").unwrap();
let swap = atomic_self_replace(&live, &new).expect("swap should succeed");
assert_eq!(std::fs::read(&live).unwrap(), b"NEW-BINARY");
assert!(swap.previous_backup.is_file());
assert_eq!(std::fs::read(&swap.previous_backup).unwrap(), b"OLD-BINARY");
assert_eq!(swap.previous_backup, previous_backup_path(&live));
}
#[test]
fn source_built_carveout_is_cargo_aur_dnf_only() {
assert!(source_built_unverified_reason(&InstallMethod::Cargo).is_some());
assert!(source_built_unverified_reason(&InstallMethod::Aur).is_some());
assert!(source_built_unverified_reason(&InstallMethod::Dnf).is_some());
for m in [
InstallMethod::Apt,
InstallMethod::SelfManaged,
InstallMethod::Homebrew,
InstallMethod::Npm,
InstallMethod::Scoop,
InstallMethod::Unknown,
] {
assert!(
source_built_unverified_reason(&m).is_none(),
"{m:?} must not be carved out"
);
}
}
#[test]
fn verify_self_carves_out_cargo_before_any_network() {
let prov = Provenance {
version: "0.3.2".to_string(),
binary_path: Some(PathBuf::from("/home/u/.cargo/bin/tirith")),
binary_sha256: Some("ab".repeat(32)),
install_method: InstallMethod::Cargo,
target: Some("x86_64-unknown-linux-gnu".to_string()),
dev_build: false,
path_resolution_failed: false,
};
let outcome = run_verify_self(&prov);
assert!(
matches!(outcome.status, VerificationStatus::Unverified { .. }),
"cargo install must be Unverified, got {:?}",
outcome.status
);
assert!(
!outcome.operational_error,
"source-built Unverified is benign and must not exit non-zero"
);
}
#[test]
fn benign_mismatch_reason_is_homebrew_only() {
let hb = benign_mismatch_reason(&InstallMethod::Homebrew);
assert!(hb.is_some());
assert!(hb.unwrap().contains("sheeki03/tap/tirith"));
for m in [
InstallMethod::SelfManaged,
InstallMethod::Npm,
InstallMethod::Scoop,
InstallMethod::Apt,
InstallMethod::Cargo,
InstallMethod::Aur,
InstallMethod::Dnf,
InstallMethod::Unknown,
] {
assert!(
benign_mismatch_reason(&m).is_none(),
"{m:?} mismatch must stay Failed (tampering), not downgrade"
);
}
}
#[test]
fn rollback_from_backup_restores_original() {
let dir = tempfile::tempdir().unwrap();
let live = dir.path().join("tirith");
let new = dir.path().join("new-tirith");
std::fs::write(&live, b"V1").unwrap();
std::fs::write(&new, b"V2").unwrap();
let swap = atomic_self_replace(&live, &new).unwrap();
assert_eq!(std::fs::read(&live).unwrap(), b"V2");
atomic_restore_from(&live, &swap.previous_backup).unwrap();
assert_eq!(std::fs::read(&live).unwrap(), b"V1");
}
#[test]
fn atomic_restore_from_does_not_clobber_source_at_backup_path() {
let dir = tempfile::tempdir().unwrap();
let live = dir.path().join("tirith");
std::fs::write(&live, b"CURRENT-BYTES").unwrap();
let backup = previous_backup_path(&live);
std::fs::write(&backup, b"PREVIOUS-BYTES").unwrap();
atomic_restore_from(&live, &backup).unwrap();
assert_eq!(std::fs::read(&live).unwrap(), b"PREVIOUS-BYTES");
}
#[cfg(unix)]
#[test]
fn atomic_restore_from_sets_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let live = dir.path().join("tirith");
let src = dir.path().join("backup");
std::fs::write(&live, b"live").unwrap();
std::fs::write(&src, b"restored").unwrap();
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o600)).unwrap();
atomic_restore_from(&live, &src).unwrap();
let mode = std::fs::metadata(&live).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "restored binary must be executable");
}
#[cfg(unix)]
#[test]
fn atomic_self_replace_sets_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let live = dir.path().join("tirith");
let new = dir.path().join("new-tirith");
std::fs::write(&live, b"old").unwrap();
std::fs::write(&new, b"new").unwrap();
std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o644)).unwrap();
atomic_self_replace(&live, &new).unwrap();
let mode = std::fs::metadata(&live).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "swapped-in binary must be executable");
}
#[test]
fn previous_backup_path_is_next_to_binary() {
let p = Path::new("/Users/alice/.local/bin/tirith");
let b = previous_backup_path(p);
assert_eq!(
b,
PathBuf::from("/Users/alice/.local/bin/tirith.tirith-previous")
);
}
#[test]
fn dir_is_writable_true_for_tempdir() {
let dir = tempfile::tempdir().unwrap();
assert!(dir_is_writable(dir.path()));
}
#[test]
fn dir_is_writable_false_for_nonexistent_dir() {
assert!(!dir_is_writable(Path::new("/nonexistent/tirith/xyz")));
}
#[test]
fn hex_sha256_known_value() {
assert_eq!(
hex_sha256(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn hash_file_opt_reads_and_hashes() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("data");
std::fs::write(&f, b"").unwrap();
assert_eq!(
hash_file_opt(&f).unwrap(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(hash_file_opt(Path::new("/nonexistent/x")), None);
}
#[test]
fn short_truncates_to_twelve() {
assert_eq!(short(&"a".repeat(64)), "aaaaaaaaaaaa");
assert_eq!(short("abc"), "abc");
}
#[test]
fn archive_verify_fails_on_checksum_mismatch() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
std::fs::write(&archive, b"TAMPERED ARCHIVE BYTES").unwrap();
let checksums = dir.path().join("checksums.txt");
let txt = format!(
"{} tirith-x86_64-unknown-linux-gnu.tar.gz\n",
"0".repeat(64)
);
std::fs::write(&checksums, &txt).unwrap();
let release = ReleaseSet {
archive_path: archive,
checksums_txt: txt,
sig_path: None,
cert_path: None,
checksums_path: checksums,
};
let verdict =
verify_archive_against_checksums(&release, "tirith-x86_64-unknown-linux-gnu.tar.gz");
assert!(matches!(verdict, ArchiveVerdict::Failed(_)));
}
#[test]
fn archive_verify_ok_checksum_only_without_signature() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
let body = b"REAL ARCHIVE BYTES";
std::fs::write(&archive, body).unwrap();
let real_digest = hex_sha256(body);
let txt = format!("{real_digest} tirith-x86_64-unknown-linux-gnu.tar.gz\n");
let checksums = dir.path().join("checksums.txt");
std::fs::write(&checksums, &txt).unwrap();
let release = ReleaseSet {
archive_path: archive,
checksums_txt: txt,
sig_path: None, cert_path: None,
checksums_path: checksums,
};
let verdict =
verify_archive_against_checksums(&release, "tirith-x86_64-unknown-linux-gnu.tar.gz");
match verdict {
ArchiveVerdict::Ok {
signed,
cosign_note,
} => {
assert_eq!(signed, ChecksumStrength::ChecksumOnly);
let note = cosign_note.expect("checksum-only must carry a cosign note");
assert!(
note.contains("did not publish"),
"note should explain no signature was published, got: {note}"
);
}
_ => panic!("expected Ok(ChecksumOnly), got a different verdict"),
}
}
#[test]
fn archive_verify_checksum_missing_when_no_entry() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
std::fs::write(&archive, b"bytes").unwrap();
let txt = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef some-other-file.tar.gz\n";
let checksums = dir.path().join("checksums.txt");
std::fs::write(&checksums, txt).unwrap();
let release = ReleaseSet {
archive_path: archive,
checksums_txt: txt.to_string(),
sig_path: None,
cert_path: None,
checksums_path: checksums,
};
let verdict =
verify_archive_against_checksums(&release, "tirith-x86_64-unknown-linux-gnu.tar.gz");
assert!(matches!(verdict, ArchiveVerdict::ChecksumMissing(_)));
}
#[test]
fn local_status_dev_build_is_unverified() {
let prov = Provenance {
version: "0.3.1".to_string(),
binary_path: Some(PathBuf::from("/src/tirith/target/release/tirith")),
binary_sha256: Some("a".repeat(64)),
install_method: InstallMethod::Unknown,
target: Some("x86_64-unknown-linux-gnu".to_string()),
dev_build: true,
path_resolution_failed: false,
};
let status = local_verification_status(&prov);
assert_eq!(status.token(), "unverified");
assert!(matches!(status, VerificationStatus::Unverified { .. }));
}
#[test]
fn verify_self_dev_build_short_circuits_offline() {
let prov = Provenance {
version: "0.3.1".to_string(),
binary_path: Some(PathBuf::from("/home/dev/tirith/target/release/tirith")),
binary_sha256: Some("b".repeat(64)),
install_method: InstallMethod::SelfManaged,
target: Some("x86_64-unknown-linux-gnu".to_string()),
dev_build: true,
path_resolution_failed: false,
};
let outcome = run_verify_self(&prov);
assert!(matches!(
outcome.status,
VerificationStatus::Unverified { .. }
));
assert_eq!(outcome.status.token(), "unverified");
assert!(
!outcome.operational_error,
"a dev build is an honest unverified, not an operational error"
);
}
#[test]
fn verify_self_unpublished_platform_short_circuits() {
let prov = Provenance {
version: "0.3.1".to_string(),
binary_path: Some(PathBuf::from("/usr/bin/tirith")),
binary_sha256: Some("c".repeat(64)),
install_method: InstallMethod::Unknown,
target: None, dev_build: false,
path_resolution_failed: false,
};
let outcome = run_verify_self(&prov);
assert!(matches!(
outcome.status,
VerificationStatus::Unverified { .. }
));
assert!(
!outcome.operational_error,
"an unpublished platform is an honest unverified, not an operational error"
);
}
#[test]
fn verify_self_unreadable_own_binary_is_operational_error() {
let prov = Provenance {
version: "0.3.1".to_string(),
binary_path: Some(PathBuf::from("/usr/local/bin/tirith")),
binary_sha256: None, install_method: InstallMethod::SelfManaged,
target: Some("x86_64-unknown-linux-gnu".to_string()),
dev_build: false,
path_resolution_failed: false,
};
let outcome = run_verify_self(&prov);
assert!(
outcome.operational_error,
"an unreadable own-binary is an operational error, not a benign unverified"
);
assert!(matches!(
outcome.status,
VerificationStatus::Unverified { .. }
));
}
#[test]
fn verify_self_unknown_own_path_is_operational_error() {
let prov = Provenance {
version: "0.3.1".to_string(),
binary_path: None,
binary_sha256: None,
install_method: InstallMethod::SelfManaged,
target: Some("x86_64-unknown-linux-gnu".to_string()),
dev_build: false,
path_resolution_failed: false,
};
let outcome = run_verify_self(&prov);
assert!(
outcome.operational_error,
"an unknown own-binary path is an operational error"
);
assert!(matches!(
outcome.status,
VerificationStatus::Unverified { .. }
));
}
#[cfg(unix)]
#[test]
fn extract_tirith_binary_finds_member_in_targz() {
let dir = tempfile::tempdir().unwrap();
let stage = dir.path().join("stage");
std::fs::create_dir_all(&stage).unwrap();
std::fs::write(stage.join("tirith"), b"BINARY-CONTENT").unwrap();
std::fs::write(stage.join("README"), b"decoy").unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
let ok = std::process::Command::new("tar")
.arg("czf")
.arg(&archive)
.arg("-C")
.arg(&stage)
.arg("tirith")
.arg("README")
.status()
.expect("tar should run")
.success();
assert!(ok, "tar czf should succeed");
let extracted =
extract_tirith_binary(&archive, "x86_64-unknown-linux-gnu", dir.path()).unwrap();
assert_eq!(std::fs::read(&extracted).unwrap(), b"BINARY-CONTENT");
}
#[cfg(unix)]
#[test]
fn extract_tirith_binary_errors_when_no_binary() {
let dir = tempfile::tempdir().unwrap();
let stage = dir.path().join("stage");
std::fs::create_dir_all(&stage).unwrap();
std::fs::write(stage.join("NOT-tirith"), b"x").unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
std::process::Command::new("tar")
.arg("czf")
.arg(&archive)
.arg("-C")
.arg(&stage)
.arg("NOT-tirith")
.status()
.unwrap();
let r = extract_tirith_binary(&archive, "x86_64-unknown-linux-gnu", dir.path());
assert!(r.is_err());
}
#[cfg(unix)]
#[test]
fn extract_tirith_binary_rejects_symlink_escaping_extract_dir() {
let dir = tempfile::tempdir().unwrap();
let outside_secret = dir.path().join("outside-secret");
std::fs::write(&outside_secret, b"SENSITIVE-BYTES-OUTSIDE").unwrap();
let stage = dir.path().join("stage");
std::fs::create_dir_all(&stage).unwrap();
std::os::unix::fs::symlink("../../outside-secret", stage.join("tirith")).unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
let ok = std::process::Command::new("tar")
.arg("czf")
.arg(&archive)
.arg("-C")
.arg(&stage)
.arg("tirith")
.status()
.expect("tar should run")
.success();
assert!(ok, "tar czf should succeed");
let workdir = dir.path().join("work");
std::fs::create_dir_all(&workdir).unwrap();
let r = extract_tirith_binary(&archive, "x86_64-unknown-linux-gnu", &workdir);
assert!(
r.is_err(),
"an escaping-symlink `tirith` member must be rejected, got: {r:?}"
);
let err = r.unwrap_err();
assert!(
err.contains("OUTSIDE") || err.contains("path-traversal"),
"rejection should name the containment violation, got: {err}"
);
assert_eq!(
std::fs::read(&outside_secret).unwrap(),
b"SENSITIVE-BYTES-OUTSIDE"
);
}
#[cfg(unix)]
#[test]
fn extract_tirith_binary_dotdot_member_writes_nothing_outside() {
let dir = tempfile::tempdir().unwrap();
let stage = dir.path().join("stage");
std::fs::create_dir_all(&stage).unwrap();
std::fs::write(stage.join("tirith"), b"PAYLOAD").unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
let ok = std::process::Command::new("tar")
.arg("czf")
.arg(&archive)
.arg("-C")
.arg(&stage)
.arg("--transform")
.arg("s,^tirith,../escaped-tirith,")
.arg("tirith")
.status();
let renamed = ok.map(|s| s.success()).unwrap_or(false);
let workdir = dir.path().join("work");
std::fs::create_dir_all(&workdir).unwrap();
let leak = workdir.join("escaped-tirith");
let _ = extract_tirith_binary(&archive, "x86_64-unknown-linux-gnu", &workdir);
if renamed {
assert!(
!leak.exists(),
"a `../`-prefixed archive member must not be written outside the extract dir"
);
}
}
#[cfg(unix)]
#[test]
fn extract_tirith_binary_preexisting_in_bounds_file_is_allowed() {
let dir = tempfile::tempdir().unwrap();
let stage = dir.path().join("stage");
std::fs::create_dir_all(&stage).unwrap();
std::fs::write(stage.join("tirith"), b"ARCHIVE-BINARY").unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
std::process::Command::new("tar")
.arg("czf")
.arg(&archive)
.arg("-C")
.arg(&stage)
.arg("tirith")
.status()
.unwrap();
let workdir = dir.path().join("work");
let pre = workdir.join("extracted");
std::fs::create_dir_all(&pre).unwrap();
std::fs::write(pre.join("tirith"), b"PRE-EXISTING").unwrap();
let extracted =
extract_tirith_binary(&archive, "x86_64-unknown-linux-gnu", &workdir).unwrap();
assert!(extracted.starts_with(pre.canonicalize().unwrap()));
}
#[cfg(unix)]
#[test]
fn cosign_failure_makes_archive_verdict_failed() {
use crate::cli::test_harness::{EnvGuard, ENV_LOCK};
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let fake_bin_dir = dir.path().join("fakebin");
std::fs::create_dir_all(&fake_bin_dir).unwrap();
let fake_cosign = fake_bin_dir.join("cosign");
std::fs::write(
&fake_cosign,
"#!/bin/sh\necho 'fake cosign: signature did not verify' 1>&2\nexit 1\n",
)
.unwrap();
std::fs::set_permissions(&fake_cosign, std::fs::Permissions::from_mode(0o755)).unwrap();
let archive = dir.path().join("tirith-x86_64-unknown-linux-gnu.tar.gz");
let body = b"REAL ARCHIVE BYTES FOR COSIGN TEST";
std::fs::write(&archive, body).unwrap();
let real_digest = hex_sha256(body);
let txt = format!("{real_digest} tirith-x86_64-unknown-linux-gnu.tar.gz\n");
let checksums = dir.path().join("checksums.txt");
std::fs::write(&checksums, &txt).unwrap();
let sig = dir.path().join("checksums.txt.sig");
std::fs::write(&sig, b"dummy-signature").unwrap();
let cert = dir.path().join("checksums.txt.pem");
std::fs::write(&cert, b"dummy-certificate").unwrap();
let release = ReleaseSet {
archive_path: archive,
checksums_txt: txt,
sig_path: Some(sig),
cert_path: Some(cert),
checksums_path: checksums,
};
let verdict = {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut entries = vec![fake_bin_dir.clone()];
if let Some(p) = std::env::var_os("PATH") {
entries.extend(std::env::split_paths(&p));
}
let joined = std::env::join_paths(entries).expect("join PATH");
let _path_guard = EnvGuard::set("PATH", std::path::Path::new(&joined));
verify_archive_against_checksums(&release, "tirith-x86_64-unknown-linux-gnu.tar.gz")
};
match verdict {
ArchiveVerdict::Failed(reason) => {
assert!(
reason.contains("cosign"),
"the failure reason should mention cosign, got: {reason}"
);
}
other => panic!(
"a non-zero cosign exit must yield ArchiveVerdict::Failed, got a different verdict ({})",
match other {
ArchiveVerdict::Ok { .. } => "Ok",
ArchiveVerdict::ChecksumMissing(_) => "ChecksumMissing",
ArchiveVerdict::Failed(_) => unreachable!(),
}
),
}
}
#[test]
fn status_detail_carries_reason() {
let s = VerificationStatus::Unverified {
reason: "offline".to_string(),
};
assert_eq!(status_detail(&s), "offline");
let f = VerificationStatus::Failed {
reason: "mismatch".to_string(),
};
assert_eq!(status_detail(&f), "mismatch");
}
}