1use 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
19fn 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; const CACHE_FILENAME: &str = "version-check.json";
27
28fn strip_tag_prefix(tag: &str) -> &str {
30 tag.strip_prefix('v').unwrap_or(tag)
31}
32
33#[derive(Debug, Clone)]
35pub struct ReleaseInfo {
36 pub tag: String,
37 pub version: Version,
38 pub assets: Vec<ReleaseAsset>,
39}
40
41#[derive(Debug, Clone)]
43pub struct ReleaseAsset {
44 pub name: String,
45 pub download_url: String,
46 pub size: u64,
47}
48
49#[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#[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 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#[derive(Debug, Clone)]
106pub struct InstallReport {
107 pub installed_path: PathBuf,
108 pub verification_mode: VerificationMode,
109}
110
111#[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
120pub 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
127pub fn fetch_latest_release(repo: &str, printer: Option<&Printer>) -> Result<ReleaseInfo> {
129 fetch_latest_release_from(&github_api_base(), repo, printer)
130}
131
132fn 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
203pub 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 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
236fn find_checksums_asset(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
238 release
239 .assets
240 .iter()
241 .find(|a| a.name.ends_with("-checksums.txt"))
242}
243
244fn 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
253fn 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
261fn 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
355fn 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
388fn 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 let content_length: Option<u64> = response
405 .header("content-length")
406 .and_then(|v| v.parse().ok());
407
408 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 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
465fn 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
478fn 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
486fn 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
523pub 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, ¤t_exe, require_cosign, printer)
541}
542
543pub(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 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_to_file(&asset.download_url, &archive_path, printer)?;
563
564 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 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 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 #[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 crate::set_file_permissions(&new_binary, 0o755).map_err(|e| UpgradeError::InstallFailed {
647 message: format!("set permissions: {}", e),
648 })?;
649
650 atomic_replace(&new_binary, target)?;
653
654 Ok(InstallReport {
655 installed_path: target.to_path_buf(),
656 verification_mode,
657 })
658}
659
660#[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 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 fs::copy(source, tmp.path()).map_err(|e| UpgradeError::InstallFailed {
677 message: format!("copy to staging: {}", e),
678 })?;
679
680 tmp.persist(target)
682 .map_err(|e| UpgradeError::InstallFailed {
683 message: format!("atomic rename: {}", e),
684 })?;
685
686 Ok(())
687}
688
689#[cfg(windows)]
694fn atomic_replace(source: &Path, target: &Path) -> std::result::Result<(), UpgradeError> {
695 let old = target.with_extension("exe.old");
697 let _ = fs::remove_file(&old);
699 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 fs::copy(source, target).map_err(|e| UpgradeError::InstallFailed {
707 message: format!("copy {} -> {}: {}", source.posix(), target.posix(), e),
708 })?;
709 Ok(())
710}
711
712#[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 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#[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
773pub 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 crate::terminate_process(status.pid);
784 tracing::info!("terminated daemon (pid {})", status.pid);
785 true
786}
787
788#[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#[cfg(unix)]
801pub fn cleanup_old_binary() {
802 }
804
805pub 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 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 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
846pub 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 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
899pub fn invalidate_cache() {
901 if let Some(dir) = cache_dir() {
902 let _ = fs::remove_file(dir.join(CACHE_FILENAME));
903 }
904}
905
906pub fn version_check_interval() -> Duration {
908 Duration::from_secs(CACHE_TTL_SECS)
909}
910
911#[cfg(test)]
912mod tests;