agentmux_srv/backend/remote/
helper_hosts.rs1use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::sync::Mutex;
19
20use serde::{Deserialize, Serialize};
21
22const FILE_NAME: &str = "remote-helper-hosts.json";
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25struct Hosts {
26 #[serde(default)]
27 hosts: Vec<String>,
28 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29 info: BTreeMap<String, HostInfo>,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct HostInfo {
35 #[serde(default, skip_serializing_if = "String::is_empty")]
37 pub uname: String,
38 #[serde(default, skip_serializing_if = "String::is_empty")]
40 pub helper_version: String,
41 #[serde(default)]
43 pub seen_at_ms: u64,
44}
45
46static LOCK: Mutex<()> = Mutex::new(());
48
49fn path_in(config_home: &Path) -> PathBuf {
50 config_home.join(FILE_NAME)
51}
52
53fn read(path: &Path) -> Hosts {
54 std::fs::read_to_string(path)
55 .ok()
56 .and_then(|s| serde_json::from_str(&s).ok())
57 .unwrap_or_default()
58}
59
60fn write(config_home: &Path, hosts: &Hosts) -> std::io::Result<()> {
61 let path = path_in(config_home);
62 std::fs::create_dir_all(config_home)?;
63 let tmp = path.with_extension("json.tmp");
64 std::fs::write(&tmp, serde_json::to_vec_pretty(hosts).unwrap_or_default())?;
65 std::fs::rename(&tmp, &path)
66}
67
68fn canonical(connection: &str) -> Option<String> {
69 match super::ConnTarget::parse(connection) {
70 Ok(t @ super::ConnTarget::Ssh(_)) => Some(t.name()),
71 _ => None,
72 }
73}
74
75use agentmux_common::time::now_ms_u64 as now_ms;
76
77fn change_in(
80 config_home: &Path,
81 connection: &str,
82 helper: bool,
83 edit: impl FnOnce(&mut HostInfo),
84) -> std::io::Result<()> {
85 let Some(name) = canonical(connection) else {
86 return Ok(());
87 };
88 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
89 let mut hosts = read(&path_in(config_home));
90 let before = hosts.clone();
91 if helper && !hosts.hosts.contains(&name) {
92 hosts.hosts.push(name.clone());
93 }
94 let info = hosts.info.entry(name).or_default();
95 let mut edited = info.clone();
96 edit(&mut edited);
97 if edited != *info {
98 edited.seen_at_ms = now_ms();
99 *info = edited;
100 }
101 if hosts == before {
102 return Ok(());
103 }
104 write(config_home, &hosts)
105}
106
107pub fn known_in(config_home: &Path, connection: &str) -> bool {
109 let Some(name) = canonical(connection) else {
110 return false;
111 };
112 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
113 read(&path_in(config_home)).hosts.contains(&name)
114}
115
116pub fn remember_in(config_home: &Path, connection: &str, version: &str) -> std::io::Result<()> {
118 change_in(config_home, connection, true, |info| info.helper_version = version.to_string())
119}
120
121pub fn record_uname_in(config_home: &Path, connection: &str, uname: &str) -> std::io::Result<()> {
123 let uname = uname.trim();
124 if uname.is_empty() {
125 return Ok(());
126 }
127 change_in(config_home, connection, false, |info| info.uname = uname.to_string())
128}
129
130pub fn all_in(config_home: &Path) -> (Vec<String>, BTreeMap<String, HostInfo>) {
132 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
133 let hosts = read(&path_in(config_home));
134 (hosts.hosts, hosts.info)
135}
136
137pub fn known(connection: &str) -> bool {
139 known_in(&crate::backend::base::get_mux_config_dir(), connection)
140}
141
142pub fn remember(connection: &str) {
146 if let Err(e) = remember_in(
147 &crate::backend::base::get_mux_config_dir(),
148 connection,
149 env!("CARGO_PKG_VERSION"),
150 ) {
151 tracing::warn!(connection = %connection, error = %e, "could not remember a helper host");
152 }
153}
154
155pub fn record_uname(connection: &str, uname: &str) {
157 if let Err(e) = record_uname_in(&crate::backend::base::get_mux_config_dir(), connection, uname) {
158 tracing::warn!(connection = %connection, error = %e, "could not record a host's platform");
159 }
160}
161
162pub fn forget_helper_in(config_home: &Path, connection: &str) -> std::io::Result<()> {
165 let Some(name) = canonical(connection) else {
166 return Ok(());
167 };
168 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
169 let mut hosts = read(&path_in(config_home));
170 let before = hosts.clone();
171 hosts.hosts.retain(|h| h != &name);
172 if let Some(info) = hosts.info.get_mut(&name) {
173 if !info.helper_version.is_empty() {
174 info.helper_version.clear();
175 info.seen_at_ms = now_ms();
176 }
177 }
178 if hosts == before {
179 return Ok(());
180 }
181 write(config_home, &hosts)
182}
183
184pub fn forget_helper(connection: &str) -> std::io::Result<()> {
186 forget_helper_in(&crate::backend::base::get_mux_config_dir(), connection)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn a_removed_helper_is_forgotten_but_the_platform_kept() {
195 let dir = tempfile::tempdir().unwrap();
196 record_uname_in(dir.path(), "user@box", "Linux x86_64").unwrap();
197 remember_in(dir.path(), "user@box", "0.59.9").unwrap();
198 remember_in(dir.path(), "other@box", "0.59.9").unwrap();
199 forget_helper_in(dir.path(), " user@box ").unwrap();
200 assert!(!known_in(dir.path(), "user@box"));
201 assert!(known_in(dir.path(), "other@box"), "other hosts untouched");
202 let (_, info) = all_in(dir.path());
203 assert_eq!(info["user@box"].uname, "Linux x86_64");
204 assert_eq!(info["user@box"].helper_version, "");
205 }
206
207 #[test]
208 fn a_host_is_remembered_once_however_it_is_spelled() {
209 let dir = tempfile::tempdir().unwrap();
210 assert!(!known_in(dir.path(), "user@box"));
211 remember_in(dir.path(), "user@box", "0.59.9").unwrap();
212 remember_in(dir.path(), " user@box ", "0.59.9").unwrap();
213 assert!(known_in(dir.path(), "user@box"));
214 assert_eq!(read(&path_in(dir.path())).hosts.len(), 1);
215 assert!(!known_in(dir.path(), "other@box"));
216 remember_in(dir.path(), "wsl://Ubuntu", "0.59.9").unwrap();
218 assert!(!known_in(dir.path(), "wsl://Ubuntu"));
219 assert!(!known_in(dir.path(), "local"));
220 }
221
222 #[test]
223 fn a_hosts_platform_and_helper_version_are_kept_with_it() {
224 let dir = tempfile::tempdir().unwrap();
225 record_uname_in(dir.path(), "user@box", "Linux x86_64\n").unwrap();
226 assert!(!known_in(dir.path(), "user@box"), "a platform alone is not the helper");
227 remember_in(dir.path(), "user@box", "0.59.9").unwrap();
228 let (hosts, info) = all_in(dir.path());
229 assert_eq!(hosts, vec!["user@box".to_string()]);
230 let box_info = &info["user@box"];
231 assert_eq!(box_info.uname, "Linux x86_64");
232 assert_eq!(box_info.helper_version, "0.59.9");
233 assert!(box_info.seen_at_ms > 0);
234
235 remember_in(dir.path(), "user@box", "0.60.0").unwrap();
237 record_uname_in(dir.path(), "user@box", " ").unwrap();
238 let (_, info) = all_in(dir.path());
239 assert_eq!(info["user@box"].helper_version, "0.60.0");
240 assert_eq!(info["user@box"].uname, "Linux x86_64");
241 }
242
243 #[test]
244 fn a_file_from_before_the_records_still_reads() {
245 let dir = tempfile::tempdir().unwrap();
246 std::fs::write(path_in(dir.path()), r#"{ "hosts": ["user@box"] }"#).unwrap();
247 assert!(known_in(dir.path(), "user@box"));
248 let (_, info) = all_in(dir.path());
249 assert!(info.is_empty());
250 }
251}