use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use semver::Version;
use crate::PathDisplayExt;
use crate::errors::{Result, UpgradeError};
use crate::output::{Printer, Role};
const GITHUB_API_BASE: &str = "https://api.github.com";
const GITHUB_API_BASE_ENV: &str = "CFGD_GITHUB_API_BASE";
const DEFAULT_REPO: &str = "tj-smith47/cfgd";
fn github_api_base() -> String {
std::env::var(GITHUB_API_BASE_ENV).unwrap_or_else(|_| GITHUB_API_BASE.to_string())
}
const CACHE_TTL_SECS: u64 = 86400; const CACHE_FILENAME: &str = "version-check.json";
fn strip_tag_prefix(tag: &str) -> &str {
tag.strip_prefix('v').unwrap_or(tag)
}
#[derive(Debug, Clone)]
pub struct ReleaseInfo {
pub tag: String,
pub version: Version,
pub assets: Vec<ReleaseAsset>,
}
#[derive(Debug, Clone)]
pub struct ReleaseAsset {
pub name: String,
pub download_url: String,
pub size: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct VersionCache {
checked_at_secs: u64,
latest_tag: String,
latest_version: String,
current_version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum VerificationMode {
#[serde(rename = "cosign")]
Cosign,
#[serde(rename = "sha256-only")]
Sha256Only,
#[serde(rename = "strict-cosign-required")]
StrictCosignRequired,
}
impl VerificationMode {
pub fn as_wire_str(self) -> &'static str {
match self {
VerificationMode::Cosign => "cosign",
VerificationMode::Sha256Only => "sha256-only",
VerificationMode::StrictCosignRequired => "strict-cosign-required",
}
}
}
#[derive(Debug, Clone)]
pub struct InstallReport {
pub installed_path: PathBuf,
pub verification_mode: VerificationMode,
}
#[derive(Debug, Clone)]
pub struct UpdateCheck {
pub current: Version,
pub latest: Version,
pub update_available: bool,
pub release: Option<ReleaseInfo>,
}
pub fn current_version() -> std::result::Result<Version, UpgradeError> {
Version::parse(env!("CARGO_PKG_VERSION")).map_err(|e| UpgradeError::VersionParse {
message: format!("cannot parse compiled version: {}", e),
})
}
pub fn fetch_latest_release(repo: &str, printer: Option<&Printer>) -> Result<ReleaseInfo> {
fetch_latest_release_from(&github_api_base(), repo, printer)
}
fn fetch_latest_release_from(
api_base: &str,
repo: &str,
printer: Option<&Printer>,
) -> Result<ReleaseInfo> {
let url = format!("{}/repos/{}/releases/latest", api_base, repo);
let spinner = printer.map(|p| p.spinner("Checking for latest release..."));
let agent = crate::http::http_agent(crate::http::HTTP_UPGRADE_TIMEOUT);
let response = agent
.get(&url)
.set("Accept", "application/vnd.github+json")
.set("User-Agent", "cfgd-self-update")
.call()
.map_err(|e| UpgradeError::ApiError {
message: format!("{}", e),
})?;
let body: String = response.into_string().map_err(|e| UpgradeError::ApiError {
message: format!("failed to read response body: {}", e),
})?;
if let Some(s) = spinner {
let _ = s.finish_ok("Checked latest release");
}
parse_release_json(&body)
}
fn parse_release_json(body: &str) -> Result<ReleaseInfo> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| UpgradeError::ApiError {
message: format!("invalid JSON: {}", e),
})?;
let tag = json["tag_name"]
.as_str()
.ok_or_else(|| UpgradeError::ApiError {
message: "missing tag_name in release".into(),
})?
.to_string();
let version_str = strip_tag_prefix(&tag);
let version = Version::parse(version_str).map_err(|e| UpgradeError::VersionParse {
message: format!("cannot parse release version '{}': {}", tag, e),
})?;
let assets = json["assets"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|a| {
Some(ReleaseAsset {
name: a["name"].as_str()?.to_string(),
download_url: a["browser_download_url"].as_str()?.to_string(),
size: a["size"].as_u64().unwrap_or(0),
})
})
.collect()
})
.unwrap_or_default();
Ok(ReleaseInfo {
tag,
version,
assets,
})
}
pub fn find_asset_for_platform(
release: &ReleaseInfo,
) -> std::result::Result<&ReleaseAsset, UpgradeError> {
let os = std::env::consts::OS;
let archive_arch = std::env::consts::ARCH;
let archive_os = match os {
"macos" => "darwin",
other => other,
};
let version_str = strip_tag_prefix(&release.tag);
#[cfg(unix)]
let archive_suffix = ".tar.gz";
#[cfg(windows)]
let archive_suffix = ".zip";
let expected_name = format!(
"cfgd-{}-{}-{}{}",
version_str, archive_os, archive_arch, archive_suffix
);
release
.assets
.iter()
.find(|a| a.name == expected_name)
.ok_or_else(|| UpgradeError::NoAsset {
os: archive_os.to_string(),
arch: archive_arch.to_string(),
})
}
fn find_checksums_asset(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
release
.assets
.iter()
.find(|a| a.name.ends_with("-checksums.txt"))
}
fn find_cosign_bundle_asset(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
release
.assets
.iter()
.find(|a| a.name.ends_with("-checksums.txt.cosign.bundle"))
}
fn find_cosign_public_key_asset(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
release
.assets
.iter()
.find(|a| a.name == "cosign.pub" || a.name.ends_with("-cosign.pub"))
}
fn verify_cosign_bundle(
checksums_path: &Path,
release: &ReleaseInfo,
tmp_dir: &Path,
require_cosign: bool,
printer: Option<&Printer>,
) -> std::result::Result<VerificationMode, UpgradeError> {
let Some(bundle_asset) = find_cosign_bundle_asset(release) else {
let reason = "no cosign bundle attached to release";
if require_cosign {
return Err(UpgradeError::CosignRequired {
reason: reason.to_string(),
});
}
if let Some(p) = printer {
p.status_simple(Role::Warn, "no cosign bundle attached to release — falling back to SHA256-only checksum verification. Downgrades publisher-compromise resistance to GitHub Releases trust.");
}
return Ok(VerificationMode::Sha256Only);
};
let Some(pub_key_asset) = find_cosign_public_key_asset(release) else {
let reason = "cosign bundle found but no cosign.pub attached to release";
if require_cosign {
return Err(UpgradeError::CosignRequired {
reason: reason.to_string(),
});
}
if let Some(p) = printer {
p.status_simple(Role::Warn, "cosign bundle found but no public key attached to release — cannot verify without cosign.pub. Falling back to SHA256-only.");
}
return Ok(VerificationMode::Sha256Only);
};
if crate::require_cosign().is_err() {
let reason = "cosign CLI is not installed on this host";
if require_cosign {
return Err(UpgradeError::CosignRequired {
reason: reason.to_string(),
});
}
if let Some(p) = printer {
p.status_simple(Role::Warn, "cosign bundle found but the cosign CLI is not installed — install cosign (https://docs.sigstore.dev/cosign/system_config/installation/) to enable signature verification. Falling back to SHA256-only.");
}
return Ok(VerificationMode::Sha256Only);
}
let bundle_path = tmp_dir.join(&bundle_asset.name);
download_to_file(&bundle_asset.download_url, &bundle_path, printer)?;
let pub_key_path = tmp_dir.join(&pub_key_asset.name);
download_to_file(&pub_key_asset.download_url, &pub_key_path, printer)?;
let verify_spinner = printer.map(|p| p.spinner("Verifying cosign signature..."));
let outcome = run_cosign_verify_blob(checksums_path, &bundle_path, &pub_key_path);
match &outcome {
Ok(()) => {
if let Some(s) = verify_spinner {
let _ = s.finish_ok("Verified cosign signature");
}
}
Err(e) => {
if let Some(s) = verify_spinner {
let _ = s
.finish_fail("Failed to verify cosign signature")
.detail(crate::output::collapse_to_subject_line(e));
}
}
}
outcome.map(|()| {
tracing::info!(asset = %bundle_asset.name, "cosign signature verified");
if require_cosign {
VerificationMode::StrictCosignRequired
} else {
VerificationMode::Cosign
}
})
}
fn run_cosign_verify_blob(
checksums_path: &Path,
bundle_path: &Path,
pub_key_path: &Path,
) -> std::result::Result<(), UpgradeError> {
let output = crate::cosign_cmd()
.arg("verify-blob")
.arg(format!("--key={}", pub_key_path.display()))
.arg(format!("--bundle={}", bundle_path.display()))
.arg("--")
.arg(checksums_path)
.output();
match output {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let stderr = crate::stderr_lossy_trimmed(&o);
Err(UpgradeError::DownloadFailed {
message: format!("cosign verify-blob failed: {stderr}"),
})
}
Err(e) => Err(UpgradeError::DownloadFailed {
message: format!("cosign invocation failed: {e}"),
}),
}
}
fn download_to_file(
url: &str,
dest: &Path,
printer: Option<&Printer>,
) -> std::result::Result<(), UpgradeError> {
let agent = crate::http::http_agent(crate::http::HTTP_UPGRADE_TIMEOUT);
let response = agent
.get(url)
.set("User-Agent", "cfgd-self-update")
.call()
.map_err(|e| UpgradeError::DownloadFailed {
message: format!("{}", e),
})?;
let content_length: Option<u64> = response
.header("content-length")
.and_then(|v| v.parse().ok());
let parent = dest.parent().unwrap_or(std::path::Path::new("."));
let mut tmp =
tempfile::NamedTempFile::new_in(parent).map_err(|e| UpgradeError::DownloadFailed {
message: format!("create temp file: {}", e),
})?;
const MAX_DOWNLOAD_SIZE: u64 = 256 * 1024 * 1024;
let mut reader = response.into_reader().take(MAX_DOWNLOAD_SIZE);
match (printer, content_length) {
(Some(p), Some(total)) => {
let pb = p.progress_bar(total, url);
let mut buf = [0u8; 8192];
let mut downloaded: u64 = 0;
loop {
let n = reader
.read(&mut buf)
.map_err(|e| UpgradeError::DownloadFailed {
message: format!("stream to disk: {}", e),
})?;
if n == 0 {
break;
}
std::io::Write::write_all(&mut tmp, &buf[..n]).map_err(|e| {
UpgradeError::DownloadFailed {
message: format!("stream to disk: {}", e),
}
})?;
downloaded += n as u64;
pb.set_position(downloaded);
}
pb.finish();
}
(Some(p), None) => {
let spinner = p.spinner(format!("Downloading {url}..."));
std::io::copy(&mut reader, &mut tmp).map_err(|e| UpgradeError::DownloadFailed {
message: format!("stream to disk: {}", e),
})?;
let _ = spinner.finish_ok(format!("Downloaded {url}"));
}
_ => {
std::io::copy(&mut reader, &mut tmp).map_err(|e| UpgradeError::DownloadFailed {
message: format!("stream to disk: {}", e),
})?;
}
}
tmp.persist(dest)
.map_err(|e| UpgradeError::DownloadFailed {
message: format!("rename to {}: {}", dest.posix(), e.error),
})?;
Ok(())
}
fn parse_checksums(content: &str) -> HashMap<String, String> {
content
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let filename = parts.next()?;
Some((filename.to_string(), hash.to_lowercase()))
})
.collect()
}
fn sha256_file(path: &Path) -> std::result::Result<String, UpgradeError> {
let bytes = fs::read(path).map_err(|e| UpgradeError::DownloadFailed {
message: format!("read {}: {}", path.posix(), e),
})?;
Ok(crate::sha256_hex(&bytes))
}
fn verify_archive_checksum(
archive_path: &Path,
checksums_content: &str,
asset_name: &str,
) -> std::result::Result<(), UpgradeError> {
let checksums = parse_checksums(checksums_content);
if checksums.is_empty() {
return Err(UpgradeError::ChecksumsEmpty);
}
let Some(expected) = checksums.get(asset_name) else {
return Err(UpgradeError::ChecksumMissing {
file: asset_name.to_string(),
});
};
let actual = sha256_file(archive_path)?;
if actual != *expected {
return Err(UpgradeError::ChecksumMismatch {
file: asset_name.to_string(),
});
}
Ok(())
}
pub fn download_and_install(
release: &ReleaseInfo,
asset: &ReleaseAsset,
require_cosign: bool,
printer: Option<&Printer>,
) -> Result<InstallReport> {
let current_exe = std::env::current_exe().map_err(|e| UpgradeError::InstallFailed {
message: format!("cannot determine current binary path: {}", e),
})?;
download_and_install_to(release, asset, ¤t_exe, require_cosign, printer)
}
pub(crate) fn download_and_install_to(
release: &ReleaseInfo,
asset: &ReleaseAsset,
target: &Path,
require_cosign: bool,
printer: Option<&Printer>,
) -> Result<InstallReport> {
let tmp_dir = tempfile::tempdir().map_err(|e| UpgradeError::DownloadFailed {
message: format!("create temp dir: {}", e),
})?;
let archive_path = tmp_dir.path().join(&asset.name);
download_to_file(&asset.download_url, &archive_path, printer)?;
let verification_mode = if let Some(checksums_asset) = find_checksums_asset(release) {
let checksums_path = tmp_dir.path().join(&checksums_asset.name);
download_to_file(&checksums_asset.download_url, &checksums_path, printer)?;
let mode = verify_cosign_bundle(
&checksums_path,
release,
tmp_dir.path(),
require_cosign,
printer,
)?;
let checksums_content =
fs::read_to_string(&checksums_path).map_err(|e| UpgradeError::DownloadFailed {
message: format!("read checksums: {}", e),
})?;
let verify_spinner = printer.map(|p| p.spinner("Verifying checksum..."));
let verify_result = verify_archive_checksum(&archive_path, &checksums_content, &asset.name);
match &verify_result {
Ok(()) => {
if let Some(s) = verify_spinner {
let _ = s.finish_ok("Checksum verified");
}
}
Err(e) => {
if let Some(s) = verify_spinner {
let _ = s
.finish_fail("Checksum verification failed")
.detail(crate::output::collapse_to_subject_line(e));
}
}
}
verify_result?;
tracing::debug!("checksum verified for {}", asset.name);
mode
} else {
return Err(UpgradeError::ChecksumMissing {
file: asset.name.clone(),
}
.into());
};
let extract_dir = tmp_dir.path().join("extracted");
fs::create_dir_all(&extract_dir).map_err(|e| UpgradeError::InstallFailed {
message: format!("create extract dir: {}", e),
})?;
let extract_spinner = printer.map(|p| p.spinner("Extracting archive..."));
#[cfg(unix)]
extract_tarball(&archive_path, &extract_dir)?;
#[cfg(windows)]
extract_zip(&archive_path, &extract_dir)?;
if let Some(s) = extract_spinner {
let _ = s.finish_ok("Extracted archive");
}
#[cfg(unix)]
let binary_name = "cfgd";
#[cfg(windows)]
let binary_name = "cfgd.exe";
let new_binary = extract_dir.join(binary_name);
if !new_binary.exists() {
return Err(UpgradeError::InstallFailed {
message: format!(
"extracted archive does not contain '{}' binary",
binary_name
),
}
.into());
}
crate::set_file_permissions(&new_binary, 0o755).map_err(|e| UpgradeError::InstallFailed {
message: format!("set permissions: {}", e),
})?;
atomic_replace(&new_binary, target)?;
Ok(InstallReport {
installed_path: target.to_path_buf(),
verification_mode,
})
}
#[cfg(unix)]
fn atomic_replace(source: &Path, target: &Path) -> std::result::Result<(), UpgradeError> {
let target_dir = target.parent().ok_or_else(|| UpgradeError::InstallFailed {
message: "target has no parent directory".into(),
})?;
let tmp =
tempfile::NamedTempFile::new_in(target_dir).map_err(|e| UpgradeError::InstallFailed {
message: format!("create temp file in {}: {}", target_dir.posix(), e),
})?;
fs::copy(source, tmp.path()).map_err(|e| UpgradeError::InstallFailed {
message: format!("copy to staging: {}", e),
})?;
tmp.persist(target)
.map_err(|e| UpgradeError::InstallFailed {
message: format!("atomic rename: {}", e),
})?;
Ok(())
}
#[cfg(windows)]
fn atomic_replace(source: &Path, target: &Path) -> std::result::Result<(), UpgradeError> {
let old = target.with_extension("exe.old");
let _ = fs::remove_file(&old);
if target.exists() {
fs::rename(target, &old).map_err(|e| UpgradeError::InstallFailed {
message: format!("rename {} -> {}: {}", target.posix(), old.posix(), e),
})?;
}
fs::copy(source, target).map_err(|e| UpgradeError::InstallFailed {
message: format!("copy {} -> {}: {}", source.posix(), target.posix(), e),
})?;
Ok(())
}
#[cfg(unix)]
fn extract_tarball(archive: &Path, dest: &Path) -> std::result::Result<(), UpgradeError> {
let file = fs::File::open(archive).map_err(|e| UpgradeError::InstallFailed {
message: format!("open archive {}: {}", archive.posix(), e),
})?;
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
fs::create_dir_all(dest).map_err(|e| UpgradeError::InstallFailed {
message: format!("create dest {}: {}", dest.posix(), e),
})?;
let canonical_dest = dest
.canonicalize()
.map_err(|e| UpgradeError::InstallFailed {
message: format!("canonicalize dest {}: {}", dest.posix(), e),
})?;
for entry in tar.entries().map_err(|e| UpgradeError::InstallFailed {
message: format!("iterate archive entries: {}", e),
})? {
let mut entry = entry.map_err(|e| UpgradeError::InstallFailed {
message: format!("read archive entry: {}", e),
})?;
if entry.header().entry_type().is_symlink() || entry.header().entry_type().is_hard_link() {
let path = entry.path().unwrap_or_default();
tracing::warn!(path = %path.posix(), "skipping symlink/hardlink in upgrade tarball");
continue;
}
entry
.unpack_in(&canonical_dest)
.map_err(|e| UpgradeError::InstallFailed {
message: format!("extract archive entry: {}", e),
})?;
}
Ok(())
}
#[cfg(windows)]
fn extract_zip(archive: &Path, dest: &Path) -> std::result::Result<(), UpgradeError> {
let file = fs::File::open(archive).map_err(|e| UpgradeError::InstallFailed {
message: format!("open archive {}: {}", archive.posix(), e),
})?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| UpgradeError::InstallFailed {
message: format!("read zip {}: {}", archive.posix(), e),
})?;
zip.extract(dest).map_err(|e| UpgradeError::InstallFailed {
message: format!("extract zip: {}", e),
})?;
Ok(())
}
pub fn restart_daemon_if_running() -> bool {
let status = match crate::daemon::query_daemon_status() {
Ok(Some(s)) => s,
_ => return false,
};
crate::terminate_process(status.pid);
tracing::info!("terminated daemon (pid {})", status.pid);
true
}
#[cfg(windows)]
pub fn cleanup_old_binary() {
if let Ok(exe) = std::env::current_exe() {
let old = exe.with_extension("exe.old");
let _ = fs::remove_file(old);
}
}
#[cfg(unix)]
pub fn cleanup_old_binary() {
}
pub fn check_with_cache(repo: Option<&str>, printer: Option<&Printer>) -> Result<UpdateCheck> {
let repo = repo.unwrap_or(DEFAULT_REPO);
let current = current_version()?;
if let Some(cache) = read_version_cache() {
let now = crate::unix_secs_now();
if now.saturating_sub(cache.checked_at_secs) < CACHE_TTL_SECS {
let cached_version =
Version::parse(&cache.latest_version).map_err(|e| UpgradeError::VersionParse {
message: format!("cached version: {}", e),
})?;
return Ok(UpdateCheck {
update_available: cached_version > current,
current,
latest: cached_version,
release: None,
});
}
}
let check = check_latest(Some(repo), printer)?;
let _ = write_version_cache(&VersionCache {
checked_at_secs: crate::unix_secs_now(),
latest_tag: check
.release
.as_ref()
.map(|r| r.tag.clone())
.unwrap_or_default(),
latest_version: check.latest.to_string(),
current_version: check.current.to_string(),
});
Ok(check)
}
pub fn check_latest(repo: Option<&str>, printer: Option<&Printer>) -> Result<UpdateCheck> {
let repo = repo.unwrap_or(DEFAULT_REPO);
let current = current_version()?;
let release = fetch_latest_release(repo, printer)?;
let update_available = release.version > current;
Ok(UpdateCheck {
current,
latest: release.version.clone(),
update_available,
release: Some(release),
})
}
fn cache_dir() -> Option<PathBuf> {
if let Some(home) = crate::test_home_override() {
return Some(home.join(".cache").join("cfgd"));
}
directories::ProjectDirs::from("dev", "cfgd", "cfgd").map(|dirs| dirs.cache_dir().to_path_buf())
}
fn read_version_cache() -> Option<VersionCache> {
let dir = cache_dir()?;
let path = dir.join(CACHE_FILENAME);
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
fn write_version_cache(cache: &VersionCache) -> std::result::Result<(), UpgradeError> {
let dir = cache_dir().ok_or_else(|| UpgradeError::InstallFailed {
message: "cannot determine cache directory".into(),
})?;
fs::create_dir_all(&dir).map_err(|e| UpgradeError::InstallFailed {
message: format!("create cache dir: {}", e),
})?;
let path = dir.join(CACHE_FILENAME);
let json = serde_json::to_string(cache).map_err(|e| UpgradeError::InstallFailed {
message: format!("serialize cache: {}", e),
})?;
crate::atomic_write_str(&path, &json).map_err(|e| UpgradeError::InstallFailed {
message: format!("write cache: {}", e),
})?;
Ok(())
}
pub fn invalidate_cache() {
if let Some(dir) = cache_dir() {
let _ = fs::remove_file(dir.join(CACHE_FILENAME));
}
}
pub fn version_check_interval() -> Duration {
Duration::from_secs(CACHE_TTL_SECS)
}
#[cfg(test)]
mod tests;