kimun_notes/update/channel.rs
1//! Install-channel detection. Decides whether this binary may self-update or
2//! must defer to a package manager. Detection that is ambiguous or fails is
3//! treated as notify-only: the conservative default never risks corrupting a
4//! managed install.
5//!
6//! Order of precedence:
7//! 1. The install marker (`install.toml`) written by `install.sh` — deterministic.
8//! 2. A heuristic on the canonicalised executable path.
9//!
10//! Anything that cannot be classified fails safe to notify-only.
11
12use std::env;
13use std::path::Path;
14use std::sync::OnceLock;
15
16const MARKER_FILE: &str = "install.toml";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum InstallChannel {
20 /// Installed via the official `install.sh`.
21 Script,
22 /// A manually downloaded release archive.
23 Direct,
24 /// Homebrew tap — package-manager owned, notify-only.
25 Brew,
26 /// `cargo install` — package-manager owned, notify-only.
27 Cargo,
28 /// Could not determine; treated as notify-only.
29 Unknown,
30}
31
32impl InstallChannel {
33 /// Whether kimün may replace its own binary on this channel.
34 pub fn self_update_eligible(self) -> bool {
35 matches!(self, Self::Script | Self::Direct)
36 }
37
38 /// The command a user should run to upgrade on a package-manager channel,
39 /// or `None` where self-update applies (or the channel is unknown).
40 pub fn upgrade_hint(self) -> Option<&'static str> {
41 match self {
42 Self::Brew => Some("brew upgrade kimun"),
43 Self::Cargo => Some("cargo install kimun-notes"),
44 _ => None,
45 }
46 }
47}
48
49#[derive(serde::Deserialize)]
50struct InstallMarker {
51 channel: String,
52}
53
54/// Detect how the running binary was installed. `config_dir` is kimün's config
55/// directory (where `install.sh` writes the marker).
56///
57/// The marker (cheap file read, depends on `config_dir`) is consulted first.
58/// The fallback path heuristic — `current_exe` canonicalisation plus a
59/// filesystem writability probe — is the expensive part and is invariant for
60/// the process, so only *it* is cached. Caching keyed on the result of an
61/// argument would let the first caller's `config_dir` win forever.
62pub fn detect(config_dir: &Path) -> InstallChannel {
63 if let Some(channel) = channel_from_marker(config_dir) {
64 return channel;
65 }
66 static EXE_CHANNEL: OnceLock<InstallChannel> = OnceLock::new();
67 *EXE_CHANNEL.get_or_init(channel_from_exe_path)
68}
69
70fn channel_from_marker(config_dir: &Path) -> Option<InstallChannel> {
71 let raw = std::fs::read_to_string(config_dir.join(MARKER_FILE)).ok()?;
72 let marker: InstallMarker = toml::from_str(&raw).ok()?;
73 match marker.channel.as_str() {
74 "script" => Some(InstallChannel::Script),
75 "direct" => Some(InstallChannel::Direct),
76 "brew" => Some(InstallChannel::Brew),
77 "cargo" => Some(InstallChannel::Cargo),
78 _ => None,
79 }
80}
81
82fn channel_from_exe_path() -> InstallChannel {
83 let exe = match env::current_exe().map_err(|e| e.to_string()).and_then(|p| {
84 kimun_core::SystemPath::canonical(&p)
85 .map(|p| p.into_path_buf())
86 .map_err(|e| e.to_string())
87 }) {
88 Ok(p) => p,
89 // No idea where we live — do not risk touching a managed binary.
90 Err(_) => return InstallChannel::Unknown,
91 };
92 let path = exe.to_string_lossy();
93
94 // Homebrew: an explicit prefix env var, or the Cellar layout the formula
95 // installs into (current_exe is canonicalised, so brew's bin symlink is
96 // already resolved into the Cellar path).
97 if let Ok(prefix) = env::var("HOMEBREW_PREFIX")
98 && !prefix.is_empty()
99 && path.starts_with(prefix.as_str())
100 {
101 return InstallChannel::Brew;
102 }
103 if path.contains("/Cellar/") || path.contains("/homebrew/") {
104 return InstallChannel::Brew;
105 }
106
107 // cargo install: under CARGO_HOME/bin or ~/.cargo/bin.
108 if let Ok(cargo_home) = env::var("CARGO_HOME")
109 && !cargo_home.is_empty()
110 && exe.starts_with(&cargo_home)
111 {
112 return InstallChannel::Cargo;
113 }
114 if let Ok(home) = kimun_core::system::home()
115 && exe.starts_with(home.join(".cargo").join("bin"))
116 {
117 return InstallChannel::Cargo;
118 }
119
120 // Otherwise the user placed this binary themselves. Only call it
121 // self-update eligible if its directory is actually writable: a binary in a
122 // root-owned/system location (e.g. /usr/bin, a distro package, the Nix
123 // store) must stay notify-only and never be overwritten in place, even
124 // though it is neither brew nor cargo.
125 match exe.parent() {
126 Some(dir) if dir_is_writable(dir) => InstallChannel::Direct,
127 _ => InstallChannel::Unknown,
128 }
129}
130
131/// Whether a probe file can be created in `dir` (i.e. the current user may
132/// write there). Cleans up the probe. A best-effort check used only to gate
133/// self-update eligibility; on any error it returns false (fail safe).
134fn dir_is_writable(dir: &Path) -> bool {
135 let probe = dir.join(format!(".kimun-write-probe-{}", std::process::id()));
136 match std::fs::File::create(&probe) {
137 Ok(_) => {
138 let _ = kimun_core::system::remove_file(&probe);
139 true
140 }
141 Err(_) => false,
142 }
143}