Skip to main content

cfgd_core/upgrade/
mod.rs

1// Self-update — query GitHub releases, download, verify, atomic install
2
3use std::collections::HashMap;
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use semver::Version;
10
11use crate::PathDisplayExt;
12use crate::errors::{Result, UpgradeError};
13use crate::output::{Printer, Role};
14
15const GITHUB_API_BASE: &str = "https://api.github.com";
16const GITHUB_API_BASE_ENV: &str = "CFGD_GITHUB_API_BASE";
17const DEFAULT_REPO: &str = "tj-smith47/cfgd";
18
19/// Resolve the GitHub Releases API base URL. Tests set CFGD_GITHUB_API_BASE
20/// to redirect at a mockito server; production calls fall through to the
21/// real api.github.com base.
22fn github_api_base() -> String {
23    std::env::var(GITHUB_API_BASE_ENV).unwrap_or_else(|_| GITHUB_API_BASE.to_string())
24}
25const CACHE_TTL_SECS: u64 = 86400; // 24 hours
26const CACHE_FILENAME: &str = "version-check.json";
27
28/// Strip leading 'v' from a git tag to get the bare version string.
29fn strip_tag_prefix(tag: &str) -> &str {
30    tag.strip_prefix('v').unwrap_or(tag)
31}
32
33/// Information about a GitHub release.
34#[derive(Debug, Clone)]
35pub struct ReleaseInfo {
36    pub tag: String,
37    pub version: Version,
38    pub assets: Vec<ReleaseAsset>,
39}
40
41/// A downloadable asset attached to a release.
42#[derive(Debug, Clone)]
43pub struct ReleaseAsset {
44    pub name: String,
45    pub download_url: String,
46    pub size: u64,
47}
48
49/// Cached version check result, persisted to disk.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51#[serde(rename_all = "camelCase")]
52struct VersionCache {
53    checked_at_secs: u64,
54    latest_tag: String,
55    latest_version: String,
56    current_version: String,
57}
58
59/// How the upgrade checksum file was verified. Surfaced in the structured
60/// `UpgradeOutput` payload so consumers (CI, alerting) can detect when an
61/// upgrade silently fell back to SHA256-only and react.
62///
63/// * `Cosign` — full cosign signature verified against the release's bundle +
64///   public key. Strongest guarantee: a publisher-compromise attacker without
65///   the cosign private key cannot forge a passing release.
66/// * `Sha256Only` — cosign bundle, public key, or the `cosign` CLI was
67///   unavailable; verification fell through to `checksums.txt` SHA256
68///   comparison only. Trusts the GitHub Releases publisher chain.
69/// * `StrictCosignRequired` — strict cosign mode was requested by the caller
70///   (`--require-cosign` / `CFGD_REQUIRE_COSIGN=1`) and verification
71///   succeeded under that policy. Distinct from `Cosign` so audit consumers
72///   can tell apart "strict was demanded" from "strict happened by accident."
73///
74/// JSON wire values are hyphenated (`cosign`, `sha256-only`,
75/// `strict-cosign-required`) — chosen for legibility in structured payloads.
76/// Variants spell the rename out per-variant rather than via a blanket
77/// `rename_all` because the workspace audit gate forbids the blanket form.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
79pub enum VerificationMode {
80    #[serde(rename = "cosign")]
81    Cosign,
82    #[serde(rename = "sha256-only")]
83    Sha256Only,
84    #[serde(rename = "strict-cosign-required")]
85    StrictCosignRequired,
86}
87
88impl VerificationMode {
89    /// The wire/JSON form of the mode, matching the per-variant serde renames.
90    /// Used by callers that emit ad-hoc JSON payloads (e.g. the upgrade CLI)
91    /// without round-tripping through `serde_json::to_value`.
92    pub fn as_wire_str(self) -> &'static str {
93        match self {
94            VerificationMode::Cosign => "cosign",
95            VerificationMode::Sha256Only => "sha256-only",
96            VerificationMode::StrictCosignRequired => "strict-cosign-required",
97        }
98    }
99}
100
101/// Outcome of [`download_and_install`] — the installed path plus the
102/// verification mode that was actually exercised. The latter lets the CLI
103/// surface a `verificationMode` field in its structured-output payload so
104/// downstream consumers can alert on silent SHA256-only fallback.
105#[derive(Debug, Clone)]
106pub struct InstallReport {
107    pub installed_path: PathBuf,
108    pub verification_mode: VerificationMode,
109}
110
111/// Result of a version check.
112#[derive(Debug, Clone)]
113pub struct UpdateCheck {
114    pub current: Version,
115    pub latest: Version,
116    pub update_available: bool,
117    pub release: Option<ReleaseInfo>,
118}
119
120/// Return the compiled-in version of cfgd.
121pub fn current_version() -> std::result::Result<Version, UpgradeError> {
122    Version::parse(env!("CARGO_PKG_VERSION")).map_err(|e| UpgradeError::VersionParse {
123        message: format!("cannot parse compiled version: {}", e),
124    })
125}
126
127/// Query the GitHub Releases API for the latest release.
128pub fn fetch_latest_release(repo: &str, printer: Option<&Printer>) -> Result<ReleaseInfo> {
129    fetch_latest_release_from(&github_api_base(), repo, printer)
130}
131
132/// Query a releases API for the latest release (testable with custom base URL).
133fn fetch_latest_release_from(
134    api_base: &str,
135    repo: &str,
136    printer: Option<&Printer>,
137) -> Result<ReleaseInfo> {
138    let url = format!("{}/repos/{}/releases/latest", api_base, repo);
139
140    let spinner = printer.map(|p| p.spinner("Checking for latest release..."));
141
142    let agent = crate::http::http_agent(crate::http::HTTP_UPGRADE_TIMEOUT);
143    let response = agent
144        .get(&url)
145        .set("Accept", "application/vnd.github+json")
146        .set("User-Agent", "cfgd-self-update")
147        .call()
148        .map_err(|e| UpgradeError::ApiError {
149            message: format!("{}", e),
150        })?;
151
152    let body: String = response.into_string().map_err(|e| UpgradeError::ApiError {
153        message: format!("failed to read response body: {}", e),
154    })?;
155
156    if let Some(s) = spinner {
157        let _ = s.finish_ok("Checked latest release");
158    }
159
160    parse_release_json(&body)
161}
162
163fn parse_release_json(body: &str) -> Result<ReleaseInfo> {
164    let json: serde_json::Value =
165        serde_json::from_str(body).map_err(|e| UpgradeError::ApiError {
166            message: format!("invalid JSON: {}", e),
167        })?;
168
169    let tag = json["tag_name"]
170        .as_str()
171        .ok_or_else(|| UpgradeError::ApiError {
172            message: "missing tag_name in release".into(),
173        })?
174        .to_string();
175
176    let version_str = strip_tag_prefix(&tag);
177    let version = Version::parse(version_str).map_err(|e| UpgradeError::VersionParse {
178        message: format!("cannot parse release version '{}': {}", tag, e),
179    })?;
180
181    let assets = json["assets"]
182        .as_array()
183        .map(|arr| {
184            arr.iter()
185                .filter_map(|a| {
186                    Some(ReleaseAsset {
187                        name: a["name"].as_str()?.to_string(),
188                        download_url: a["browser_download_url"].as_str()?.to_string(),
189                        size: a["size"].as_u64().unwrap_or(0),
190                    })
191                })
192                .collect()
193        })
194        .unwrap_or_default();
195
196    Ok(ReleaseInfo {
197        tag,
198        version,
199        assets,
200    })
201}
202
203/// Find the correct binary asset for the current OS and architecture.
204pub fn find_asset_for_platform(
205    release: &ReleaseInfo,
206) -> std::result::Result<&ReleaseAsset, UpgradeError> {
207    let os = std::env::consts::OS;
208    let archive_arch = std::env::consts::ARCH;
209
210    let archive_os = match os {
211        "macos" => "darwin",
212        other => other,
213    };
214
215    // Look for: cfgd-<version>-<os>-<arch>.tar.gz (Unix) or .zip (Windows)
216    let version_str = strip_tag_prefix(&release.tag);
217    #[cfg(unix)]
218    let archive_suffix = ".tar.gz";
219    #[cfg(windows)]
220    let archive_suffix = ".zip";
221    let expected_name = format!(
222        "cfgd-{}-{}-{}{}",
223        version_str, archive_os, archive_arch, archive_suffix
224    );
225
226    release
227        .assets
228        .iter()
229        .find(|a| a.name == expected_name)
230        .ok_or_else(|| UpgradeError::NoAsset {
231            os: archive_os.to_string(),
232            arch: archive_arch.to_string(),
233        })
234}
235
236/// Find the checksums asset for a release.
237fn find_checksums_asset(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
238    release
239        .assets
240        .iter()
241        .find(|a| a.name.ends_with("-checksums.txt"))
242}
243
244/// Find the cosign signature bundle for the checksums asset. Produced by the
245/// `checksum-cosign` entry in `.anodizer.yaml`.
246fn find_cosign_bundle_asset(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
247    release
248        .assets
249        .iter()
250        .find(|a| a.name.ends_with("-checksums.txt.cosign.bundle"))
251}
252
253/// Find a cosign public key asset, if shipped with the release.
254fn find_cosign_public_key_asset(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
255    release
256        .assets
257        .iter()
258        .find(|a| a.name == "cosign.pub" || a.name.ends_with("-cosign.pub"))
259}
260
261/// Verify `checksums_path` against the release's cosign bundle + public key if
262/// all pieces are present and the `cosign` CLI is installed.
263///
264/// Behavior depends on `require_cosign`:
265///
266/// * **`require_cosign = false`** (default): graceful degradation. Missing
267///   bundle, missing public key, or missing cosign CLI all return
268///   `Ok(VerificationMode::Sha256Only)` after surfacing a `Role::Warn` so the
269///   caller falls back to SHA256-only verification. A successful cosign
270///   verify returns `Ok(VerificationMode::Cosign)`. An *explicit* cosign
271///   verify failure (binary present, pieces present, bad signature) returns
272///   `Err` — never proceed in that case.
273///
274/// * **`require_cosign = true`** (caller opted into strict mode via
275///   `--require-cosign` / `CFGD_REQUIRE_COSIGN`): any of the three skip
276///   conditions returns `Err(UpgradeError::CosignRequired { .. })` naming the
277///   specific missing piece, blocking the upgrade. A successful verify
278///   returns `Ok(VerificationMode::StrictCosignRequired)` so the structured
279///   payload records that strict mode was honored.
280fn verify_cosign_bundle(
281    checksums_path: &Path,
282    release: &ReleaseInfo,
283    tmp_dir: &Path,
284    require_cosign: bool,
285    printer: Option<&Printer>,
286) -> std::result::Result<VerificationMode, UpgradeError> {
287    let Some(bundle_asset) = find_cosign_bundle_asset(release) else {
288        let reason = "no cosign bundle attached to release";
289        if require_cosign {
290            return Err(UpgradeError::CosignRequired {
291                reason: reason.to_string(),
292            });
293        }
294        if let Some(p) = printer {
295            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.");
296        }
297        return Ok(VerificationMode::Sha256Only);
298    };
299    let Some(pub_key_asset) = find_cosign_public_key_asset(release) else {
300        let reason = "cosign bundle found but no cosign.pub attached to release";
301        if require_cosign {
302            return Err(UpgradeError::CosignRequired {
303                reason: reason.to_string(),
304            });
305        }
306        if let Some(p) = printer {
307            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.");
308        }
309        return Ok(VerificationMode::Sha256Only);
310    };
311    if crate::require_cosign().is_err() {
312        let reason = "cosign CLI is not installed on this host";
313        if require_cosign {
314            return Err(UpgradeError::CosignRequired {
315                reason: reason.to_string(),
316            });
317        }
318        if let Some(p) = printer {
319            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.");
320        }
321        return Ok(VerificationMode::Sha256Only);
322    }
323
324    let bundle_path = tmp_dir.join(&bundle_asset.name);
325    download_to_file(&bundle_asset.download_url, &bundle_path, printer)?;
326    let pub_key_path = tmp_dir.join(&pub_key_asset.name);
327    download_to_file(&pub_key_asset.download_url, &pub_key_path, printer)?;
328
329    let verify_spinner = printer.map(|p| p.spinner("Verifying cosign signature..."));
330    let outcome = run_cosign_verify_blob(checksums_path, &bundle_path, &pub_key_path);
331    match &outcome {
332        Ok(()) => {
333            if let Some(s) = verify_spinner {
334                let _ = s.finish_ok("Verified cosign signature");
335            }
336        }
337        Err(e) => {
338            if let Some(s) = verify_spinner {
339                let _ = s
340                    .finish_fail("Failed to verify cosign signature")
341                    .detail(crate::output::collapse_to_subject_line(e));
342            }
343        }
344    }
345    outcome.map(|()| {
346        tracing::info!(asset = %bundle_asset.name, "cosign signature verified");
347        if require_cosign {
348            VerificationMode::StrictCosignRequired
349        } else {
350            VerificationMode::Cosign
351        }
352    })
353}
354
355/// Run `cosign verify-blob --key ... --bundle ... -- <checksums>` and translate
356/// the outcome into `Ok(())` / `Err(UpgradeError::DownloadFailed)`.
357///
358/// Extracted from [`verify_cosign_bundle`] so the cosign-shelling branches are
359/// testable through the `CFGD_COSIGN_BIN` shim (see `oci/sign/tests.rs`)
360/// without staging downloads through a mock HTTP server.
361fn run_cosign_verify_blob(
362    checksums_path: &Path,
363    bundle_path: &Path,
364    pub_key_path: &Path,
365) -> std::result::Result<(), UpgradeError> {
366    let output = crate::cosign_cmd()
367        .arg("verify-blob")
368        .arg(format!("--key={}", pub_key_path.display()))
369        .arg(format!("--bundle={}", bundle_path.display()))
370        .arg("--")
371        .arg(checksums_path)
372        .output();
373
374    match output {
375        Ok(o) if o.status.success() => Ok(()),
376        Ok(o) => {
377            let stderr = crate::stderr_lossy_trimmed(&o);
378            Err(UpgradeError::DownloadFailed {
379                message: format!("cosign verify-blob failed: {stderr}"),
380            })
381        }
382        Err(e) => Err(UpgradeError::DownloadFailed {
383            message: format!("cosign invocation failed: {e}"),
384        }),
385    }
386}
387
388/// Download a file from a URL to a local path.
389fn download_to_file(
390    url: &str,
391    dest: &Path,
392    printer: Option<&Printer>,
393) -> std::result::Result<(), UpgradeError> {
394    let agent = crate::http::http_agent(crate::http::HTTP_UPGRADE_TIMEOUT);
395    let response = agent
396        .get(url)
397        .set("User-Agent", "cfgd-self-update")
398        .call()
399        .map_err(|e| UpgradeError::DownloadFailed {
400            message: format!("{}", e),
401        })?;
402
403    // Determine content length for progress tracking
404    let content_length: Option<u64> = response
405        .header("content-length")
406        .and_then(|v| v.parse().ok());
407
408    // Stream directly to a temp file (avoids buffering entire binary in memory)
409    let parent = dest.parent().unwrap_or(std::path::Path::new("."));
410    let mut tmp =
411        tempfile::NamedTempFile::new_in(parent).map_err(|e| UpgradeError::DownloadFailed {
412            message: format!("create temp file: {}", e),
413        })?;
414
415    const MAX_DOWNLOAD_SIZE: u64 = 256 * 1024 * 1024;
416    let mut reader = response.into_reader().take(MAX_DOWNLOAD_SIZE);
417
418    // Use progress bar if we know the size, spinner otherwise
419    match (printer, content_length) {
420        (Some(p), Some(total)) => {
421            let pb = p.progress_bar(total, url);
422            let mut buf = [0u8; 8192];
423            let mut downloaded: u64 = 0;
424            loop {
425                let n = reader
426                    .read(&mut buf)
427                    .map_err(|e| UpgradeError::DownloadFailed {
428                        message: format!("stream to disk: {}", e),
429                    })?;
430                if n == 0 {
431                    break;
432                }
433                std::io::Write::write_all(&mut tmp, &buf[..n]).map_err(|e| {
434                    UpgradeError::DownloadFailed {
435                        message: format!("stream to disk: {}", e),
436                    }
437                })?;
438                downloaded += n as u64;
439                pb.set_position(downloaded);
440            }
441            pb.finish();
442        }
443        (Some(p), None) => {
444            let spinner = p.spinner(format!("Downloading {url}..."));
445            std::io::copy(&mut reader, &mut tmp).map_err(|e| UpgradeError::DownloadFailed {
446                message: format!("stream to disk: {}", e),
447            })?;
448            let _ = spinner.finish_ok(format!("Downloaded {url}"));
449        }
450        _ => {
451            std::io::copy(&mut reader, &mut tmp).map_err(|e| UpgradeError::DownloadFailed {
452                message: format!("stream to disk: {}", e),
453            })?;
454        }
455    }
456
457    tmp.persist(dest)
458        .map_err(|e| UpgradeError::DownloadFailed {
459            message: format!("rename to {}: {}", dest.posix(), e.error),
460        })?;
461
462    Ok(())
463}
464
465/// Parse a checksums.txt file into a map of filename -> hex SHA256.
466fn parse_checksums(content: &str) -> HashMap<String, String> {
467    content
468        .lines()
469        .filter_map(|line| {
470            let mut parts = line.split_whitespace();
471            let hash = parts.next()?;
472            let filename = parts.next()?;
473            Some((filename.to_string(), hash.to_lowercase()))
474        })
475        .collect()
476}
477
478/// Compute the SHA256 hex digest of a file.
479fn sha256_file(path: &Path) -> std::result::Result<String, UpgradeError> {
480    let bytes = fs::read(path).map_err(|e| UpgradeError::DownloadFailed {
481        message: format!("read {}: {}", path.posix(), e),
482    })?;
483    Ok(crate::sha256_hex(&bytes))
484}
485
486/// Verify that the archive at `archive_path` matches the SHA256 listed for
487/// `asset_name` inside the goreleaser-style `checksums.txt` body.
488///
489/// Three error branches, each distinct on the wire so operators can tell them
490/// apart in incident triage:
491/// * `ChecksumsEmpty` — `parse_checksums` produced no entries (truncation /
492///   wrong file served).
493/// * `ChecksumMissing` — the file parsed but `asset_name` is not in the list
494///   (stripped-line attack or upload race).
495/// * `ChecksumMismatch` — the file is listed but the local SHA differs
496///   (genuine corruption or interception).
497///
498/// Pure helper — split out so the three branches are testable without
499/// downloading anything.
500fn verify_archive_checksum(
501    archive_path: &Path,
502    checksums_content: &str,
503    asset_name: &str,
504) -> std::result::Result<(), UpgradeError> {
505    let checksums = parse_checksums(checksums_content);
506    if checksums.is_empty() {
507        return Err(UpgradeError::ChecksumsEmpty);
508    }
509    let Some(expected) = checksums.get(asset_name) else {
510        return Err(UpgradeError::ChecksumMissing {
511            file: asset_name.to_string(),
512        });
513    };
514    let actual = sha256_file(archive_path)?;
515    if actual != *expected {
516        return Err(UpgradeError::ChecksumMismatch {
517            file: asset_name.to_string(),
518        });
519    }
520    Ok(())
521}
522
523/// Download, verify checksum, extract, and atomically install the new binary
524/// over the running executable.
525///
526/// `require_cosign` switches the cosign verifier into strict mode: when set,
527/// any missing cosign artifact (bundle, public key, or local CLI) blocks the
528/// upgrade with [`UpgradeError::CosignRequired`] instead of silently falling
529/// back to SHA256-only. The returned [`InstallReport`] records which mode was
530/// actually exercised so structured-output consumers can detect fallbacks.
531pub fn download_and_install(
532    release: &ReleaseInfo,
533    asset: &ReleaseAsset,
534    require_cosign: bool,
535    printer: Option<&Printer>,
536) -> Result<InstallReport> {
537    let current_exe = std::env::current_exe().map_err(|e| UpgradeError::InstallFailed {
538        message: format!("cannot determine current binary path: {}", e),
539    })?;
540    download_and_install_to(release, asset, &current_exe, require_cosign, printer)
541}
542
543/// Same as [`download_and_install`], but installs over `target` instead of
544/// `current_exe()`. Crate-internal so tests can drive the full HTTP +
545/// cosign + checksum + extract flow against a tempdir without overwriting
546/// the running test binary.
547pub(crate) fn download_and_install_to(
548    release: &ReleaseInfo,
549    asset: &ReleaseAsset,
550    target: &Path,
551    require_cosign: bool,
552    printer: Option<&Printer>,
553) -> Result<InstallReport> {
554    // Create temp directory for download
555    let tmp_dir = tempfile::tempdir().map_err(|e| UpgradeError::DownloadFailed {
556        message: format!("create temp dir: {}", e),
557    })?;
558
559    let archive_path = tmp_dir.path().join(&asset.name);
560
561    // Download archive
562    download_to_file(&asset.download_url, &archive_path, printer)?;
563
564    // Download and verify checksum if available
565    let verification_mode = if let Some(checksums_asset) = find_checksums_asset(release) {
566        let checksums_path = tmp_dir.path().join(&checksums_asset.name);
567        download_to_file(&checksums_asset.download_url, &checksums_path, printer)?;
568
569        // Best-effort cosign verification of the checksums file. Bounds
570        // publisher-compromise risk: a malicious release uploader cannot
571        // forge a valid cosign signature over a tampered checksums.txt
572        // without the private key. When `require_cosign` is true, any of
573        // the three skip conditions surfaces as Err here instead of a
574        // silent fallback to SHA256-only.
575        let mode = verify_cosign_bundle(
576            &checksums_path,
577            release,
578            tmp_dir.path(),
579            require_cosign,
580            printer,
581        )?;
582
583        let checksums_content =
584            fs::read_to_string(&checksums_path).map_err(|e| UpgradeError::DownloadFailed {
585                message: format!("read checksums: {}", e),
586            })?;
587
588        let verify_spinner = printer.map(|p| p.spinner("Verifying checksum..."));
589        let verify_result = verify_archive_checksum(&archive_path, &checksums_content, &asset.name);
590        match &verify_result {
591            Ok(()) => {
592                if let Some(s) = verify_spinner {
593                    let _ = s.finish_ok("Checksum verified");
594                }
595            }
596            Err(e) => {
597                if let Some(s) = verify_spinner {
598                    let _ = s
599                        .finish_fail("Checksum verification failed")
600                        .detail(crate::output::collapse_to_subject_line(e));
601                }
602            }
603        }
604        verify_result?;
605        tracing::debug!("checksum verified for {}", asset.name);
606        mode
607    } else {
608        return Err(UpgradeError::ChecksumMissing {
609            file: asset.name.clone(),
610        }
611        .into());
612    };
613
614    // Extract the archive
615    let extract_dir = tmp_dir.path().join("extracted");
616    fs::create_dir_all(&extract_dir).map_err(|e| UpgradeError::InstallFailed {
617        message: format!("create extract dir: {}", e),
618    })?;
619
620    let extract_spinner = printer.map(|p| p.spinner("Extracting archive..."));
621    #[cfg(unix)]
622    extract_tarball(&archive_path, &extract_dir)?;
623    #[cfg(windows)]
624    extract_zip(&archive_path, &extract_dir)?;
625    if let Some(s) = extract_spinner {
626        let _ = s.finish_ok("Extracted archive");
627    }
628
629    // Find the cfgd binary in the extracted contents
630    #[cfg(unix)]
631    let binary_name = "cfgd";
632    #[cfg(windows)]
633    let binary_name = "cfgd.exe";
634    let new_binary = extract_dir.join(binary_name);
635    if !new_binary.exists() {
636        return Err(UpgradeError::InstallFailed {
637            message: format!(
638                "extracted archive does not contain '{}' binary",
639                binary_name
640            ),
641        }
642        .into());
643    }
644
645    // Make it executable (no-op on Windows)
646    crate::set_file_permissions(&new_binary, 0o755).map_err(|e| UpgradeError::InstallFailed {
647        message: format!("set permissions: {}", e),
648    })?;
649
650    // Install new binary over old.
651    // Unix: atomic rename via tempfile. Windows: rename-dance (can't overwrite running exe).
652    atomic_replace(&new_binary, target)?;
653
654    Ok(InstallReport {
655        installed_path: target.to_path_buf(),
656        verification_mode,
657    })
658}
659
660/// Atomically replace `target` with `source`.
661/// Copies source to a NamedTempFile in the target directory, then persists it
662/// over the target (atomic rename on the same filesystem).
663#[cfg(unix)]
664fn atomic_replace(source: &Path, target: &Path) -> std::result::Result<(), UpgradeError> {
665    let target_dir = target.parent().ok_or_else(|| UpgradeError::InstallFailed {
666        message: "target has no parent directory".into(),
667    })?;
668
669    // Create a temp file in the target directory so rename is same-FS
670    let tmp =
671        tempfile::NamedTempFile::new_in(target_dir).map_err(|e| UpgradeError::InstallFailed {
672            message: format!("create temp file in {}: {}", target_dir.posix(), e),
673        })?;
674
675    // Copy source to the temp file
676    fs::copy(source, tmp.path()).map_err(|e| UpgradeError::InstallFailed {
677        message: format!("copy to staging: {}", e),
678    })?;
679
680    // Persist (atomic rename) temp file to target
681    tmp.persist(target)
682        .map_err(|e| UpgradeError::InstallFailed {
683            message: format!("atomic rename: {}", e),
684        })?;
685
686    Ok(())
687}
688
689/// Replace `target` with `source` using the Windows rename-dance.
690/// Windows cannot overwrite a running executable, so we rename the current
691/// binary to `.exe.old`, copy the new one into place, and clean up `.old`
692/// on next startup via `cleanup_old_binary`.
693#[cfg(windows)]
694fn atomic_replace(source: &Path, target: &Path) -> std::result::Result<(), UpgradeError> {
695    // with_extension replaces .exe → .exe.old (not appends)
696    let old = target.with_extension("exe.old");
697    // Clean up from previous upgrades
698    let _ = fs::remove_file(&old);
699    // Rename running binary out of the way (can't overwrite running exe on Windows)
700    if target.exists() {
701        fs::rename(target, &old).map_err(|e| UpgradeError::InstallFailed {
702            message: format!("rename {} -> {}: {}", target.posix(), old.posix(), e),
703        })?;
704    }
705    // Copy new binary into place
706    fs::copy(source, target).map_err(|e| UpgradeError::InstallFailed {
707        message: format!("copy {} -> {}: {}", source.posix(), target.posix(), e),
708    })?;
709    Ok(())
710}
711
712/// Extract a .tar.gz archive to a directory.
713#[cfg(unix)]
714fn extract_tarball(archive: &Path, dest: &Path) -> std::result::Result<(), UpgradeError> {
715    let file = fs::File::open(archive).map_err(|e| UpgradeError::InstallFailed {
716        message: format!("open archive {}: {}", archive.posix(), e),
717    })?;
718
719    let gz = flate2::read::GzDecoder::new(file);
720    let mut tar = tar::Archive::new(gz);
721
722    fs::create_dir_all(dest).map_err(|e| UpgradeError::InstallFailed {
723        message: format!("create dest {}: {}", dest.posix(), e),
724    })?;
725
726    // The tar crate rejects `..` and absolute paths by default, but symlinks
727    // can still point outside `dest`. Canonicalize and iterate entries, skipping
728    // symlinks/hardlinks and unpacking each into the canonical dest.
729    let canonical_dest = dest
730        .canonicalize()
731        .map_err(|e| UpgradeError::InstallFailed {
732            message: format!("canonicalize dest {}: {}", dest.posix(), e),
733        })?;
734
735    for entry in tar.entries().map_err(|e| UpgradeError::InstallFailed {
736        message: format!("iterate archive entries: {}", e),
737    })? {
738        let mut entry = entry.map_err(|e| UpgradeError::InstallFailed {
739            message: format!("read archive entry: {}", e),
740        })?;
741
742        if entry.header().entry_type().is_symlink() || entry.header().entry_type().is_hard_link() {
743            let path = entry.path().unwrap_or_default();
744            tracing::warn!(path = %path.posix(), "skipping symlink/hardlink in upgrade tarball");
745            continue;
746        }
747
748        entry
749            .unpack_in(&canonical_dest)
750            .map_err(|e| UpgradeError::InstallFailed {
751                message: format!("extract archive entry: {}", e),
752            })?;
753    }
754
755    Ok(())
756}
757
758/// Extract a .zip archive to a directory.
759#[cfg(windows)]
760fn extract_zip(archive: &Path, dest: &Path) -> std::result::Result<(), UpgradeError> {
761    let file = fs::File::open(archive).map_err(|e| UpgradeError::InstallFailed {
762        message: format!("open archive {}: {}", archive.posix(), e),
763    })?;
764    let mut zip = zip::ZipArchive::new(file).map_err(|e| UpgradeError::InstallFailed {
765        message: format!("read zip {}: {}", archive.posix(), e),
766    })?;
767    zip.extract(dest).map_err(|e| UpgradeError::InstallFailed {
768        message: format!("extract zip: {}", e),
769    })?;
770    Ok(())
771}
772
773/// Check if the daemon is running and restart it.
774/// Returns true if the daemon was restarted, false if it wasn't running.
775pub fn restart_daemon_if_running() -> bool {
776    let status = match crate::daemon::query_daemon_status() {
777        Ok(Some(s)) => s,
778        _ => return false,
779    };
780
781    // Daemon is running — terminate so the service manager restarts it
782    // with the new binary.
783    crate::terminate_process(status.pid);
784    tracing::info!("terminated daemon (pid {})", status.pid);
785    true
786}
787
788/// Clean up the old binary left behind by the Windows rename-dance upgrade.
789/// Call this on startup. No-op on Unix.
790#[cfg(windows)]
791pub fn cleanup_old_binary() {
792    if let Ok(exe) = std::env::current_exe() {
793        let old = exe.with_extension("exe.old");
794        let _ = fs::remove_file(old);
795    }
796}
797
798/// Clean up the old binary left behind by the Windows rename-dance upgrade.
799/// Call this on startup. No-op on Unix.
800#[cfg(unix)]
801pub fn cleanup_old_binary() {
802    // Unix atomic_replace doesn't leave old files
803}
804
805/// Check for an update, using a 24h disk cache to avoid excessive API calls.
806pub fn check_with_cache(repo: Option<&str>, printer: Option<&Printer>) -> Result<UpdateCheck> {
807    let repo = repo.unwrap_or(DEFAULT_REPO);
808    let current = current_version()?;
809
810    // Try reading from cache
811    if let Some(cache) = read_version_cache() {
812        let now = crate::unix_secs_now();
813
814        if now.saturating_sub(cache.checked_at_secs) < CACHE_TTL_SECS {
815            let cached_version =
816                Version::parse(&cache.latest_version).map_err(|e| UpgradeError::VersionParse {
817                    message: format!("cached version: {}", e),
818                })?;
819
820            return Ok(UpdateCheck {
821                update_available: cached_version > current,
822                current,
823                latest: cached_version,
824                release: None,
825            });
826        }
827    }
828
829    // Cache miss or expired — fall through to fresh check + update cache
830    let check = check_latest(Some(repo), printer)?;
831
832    let _ = write_version_cache(&VersionCache {
833        checked_at_secs: crate::unix_secs_now(),
834        latest_tag: check
835            .release
836            .as_ref()
837            .map(|r| r.tag.clone())
838            .unwrap_or_default(),
839        latest_version: check.latest.to_string(),
840        current_version: check.current.to_string(),
841    });
842
843    Ok(check)
844}
845
846/// Check for an update without using cache. Always queries the API.
847pub fn check_latest(repo: Option<&str>, printer: Option<&Printer>) -> Result<UpdateCheck> {
848    let repo = repo.unwrap_or(DEFAULT_REPO);
849    let current = current_version()?;
850    let release = fetch_latest_release(repo, printer)?;
851    let update_available = release.version > current;
852
853    Ok(UpdateCheck {
854        current,
855        latest: release.version.clone(),
856        update_available,
857        release: Some(release),
858    })
859}
860
861fn cache_dir() -> Option<PathBuf> {
862    // Tests that install a test-home override get a tempdir-scoped cache
863    // directory so they don't pollute (or race against each other in) the
864    // real user cache. Production callers see the real ProjectDirs path.
865    if let Some(home) = crate::test_home_override() {
866        return Some(home.join(".cache").join("cfgd"));
867    }
868    directories::ProjectDirs::from("dev", "cfgd", "cfgd").map(|dirs| dirs.cache_dir().to_path_buf())
869}
870
871fn read_version_cache() -> Option<VersionCache> {
872    let dir = cache_dir()?;
873    let path = dir.join(CACHE_FILENAME);
874    let content = fs::read_to_string(&path).ok()?;
875    serde_json::from_str(&content).ok()
876}
877
878fn write_version_cache(cache: &VersionCache) -> std::result::Result<(), UpgradeError> {
879    let dir = cache_dir().ok_or_else(|| UpgradeError::InstallFailed {
880        message: "cannot determine cache directory".into(),
881    })?;
882
883    fs::create_dir_all(&dir).map_err(|e| UpgradeError::InstallFailed {
884        message: format!("create cache dir: {}", e),
885    })?;
886
887    let path = dir.join(CACHE_FILENAME);
888    let json = serde_json::to_string(cache).map_err(|e| UpgradeError::InstallFailed {
889        message: format!("serialize cache: {}", e),
890    })?;
891
892    crate::atomic_write_str(&path, &json).map_err(|e| UpgradeError::InstallFailed {
893        message: format!("write cache: {}", e),
894    })?;
895
896    Ok(())
897}
898
899/// Invalidate the version check cache so the next check queries the API.
900pub fn invalidate_cache() {
901    if let Some(dir) = cache_dir() {
902        let _ = fs::remove_file(dir.join(CACHE_FILENAME));
903    }
904}
905
906/// Duration for the daemon's version check timer.
907pub fn version_check_interval() -> Duration {
908    Duration::from_secs(CACHE_TTL_SECS)
909}
910
911#[cfg(test)]
912mod tests;