1use std::path::{Path, PathBuf};
13
14use microvms_app::error::{Error, ErrorKind};
15use microvms_app::names::{NameRecord, NameStore, validate_name};
16
17pub fn state_root(env: &dyn Fn(&str) -> Option<String>) -> Result<PathBuf, Error> {
22 resolve_state_root(env, || {
23 #[cfg(unix)]
24 {
25 std::env::home_dir()
27 }
28 #[cfg(not(unix))]
29 {
30 None
31 }
32 })
33}
34
35fn resolve_state_root(
36 env: &dyn Fn(&str) -> Option<String>,
37 home: impl FnOnce() -> Option<PathBuf>,
38) -> Result<PathBuf, Error> {
39 if let Some(dir) = env("MICROVM_STATE_DIR").filter(|dir| !dir.is_empty()) {
40 return Ok(PathBuf::from(dir));
41 }
42 let configured = if cfg!(windows) {
43 env("USERPROFILE").or_else(|| env("HOME"))
44 } else {
45 env("HOME")
46 };
47 let home = configured.map(PathBuf::from).or_else(home);
48 match home {
49 Some(home) if home.is_absolute() => Ok(home.join(".microvm").join("runs")),
50 _ => Err(Error::new(
51 ErrorKind::Precondition,
52 "could not determine an absolute user home directory; set MICROVM_STATE_DIR explicitly",
53 )),
54 }
55}
56
57#[derive(Clone, Debug)]
59pub struct FileNameStore {
60 dir: PathBuf,
61 unavailable: Option<String>,
62}
63
64impl FileNameStore {
65 pub fn new(dir: impl Into<PathBuf>) -> Self {
67 Self {
68 dir: dir.into(),
69 unavailable: None,
70 }
71 }
72
73 pub fn in_state_root(state_root: &Path) -> Self {
78 Self::new(state_root.join("names"))
79 }
80
81 pub fn default_location(env: &dyn Fn(&str) -> Option<String>) -> Self {
86 match state_root(env) {
87 Ok(root) => Self::in_state_root(&root),
88 Err(error) => Self {
89 dir: PathBuf::new(),
90 unavailable: Some(error.to_string()),
91 },
92 }
93 }
94
95 pub fn dir(&self) -> &Path {
97 &self.dir
98 }
99
100 pub fn path_of(&self, name: &str) -> PathBuf {
102 self.dir.join(format!("{name}.json"))
103 }
104
105 fn ready(&self) -> Result<(), Error> {
106 match &self.unavailable {
107 Some(message) => Err(Error::new(ErrorKind::Precondition, message.clone())),
108 None => Ok(()),
109 }
110 }
111
112 fn io(&self, action: &str, path: &Path, error: std::io::Error) -> Error {
113 Error::new(
114 ErrorKind::Precondition,
115 format!("could not {action} name record {}: {error}", path.display()),
116 )
117 .with_source(error)
118 }
119}
120
121impl NameStore for FileNameStore {
122 fn get(&self, name: &str) -> Result<Option<NameRecord>, Error> {
126 self.ready()?;
127 validate_name(name).map_err(Error::invalid_arg)?;
128 let path = self.path_of(name);
129 let text = match std::fs::read_to_string(&path) {
130 Ok(text) => text,
131 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
132 Err(error) => return Err(self.io("read", &path, error)),
133 };
134 serde_json::from_str(&text).map(Some).map_err(|_| {
135 Error::new(
136 ErrorKind::Precondition,
137 format!(
138 "name record {} is unreadable; the name stays taken until you inspect \
139 or delete that file",
140 path.display()
141 ),
142 )
143 })
144 }
145
146 fn put(&self, record: &NameRecord) -> Result<(), Error> {
147 self.ready()?;
148 validate_name(&record.name).map_err(Error::invalid_arg)?;
149 std::fs::create_dir_all(&self.dir).map_err(|error| self.io("create", &self.dir, error))?;
150 let path = self.path_of(&record.name);
151 let text = serde_json::to_string_pretty(record)
152 .map_err(|_| Error::new(ErrorKind::Unexpected, "a name record failed to serialize"))?;
153 write_private(&path, text.as_bytes()).map_err(|error| self.io("write", &path, error))
154 }
155
156 fn delete(&self, name: &str) -> Result<bool, Error> {
157 self.ready()?;
158 validate_name(name).map_err(Error::invalid_arg)?;
159 let path = self.path_of(name);
160 match std::fs::remove_file(&path) {
161 Ok(()) => Ok(true),
162 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
163 Err(error) => Err(self.io("delete", &path, error)),
164 }
165 }
166
167 fn describe(&self) -> String {
168 self.dir.display().to_string()
169 }
170
171 fn list(&self) -> Result<Vec<NameRecord>, Error> {
174 self.ready()?;
175 let entries = match std::fs::read_dir(&self.dir) {
176 Ok(entries) => entries,
177 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
178 Err(error) => return Err(self.io("list", &self.dir, error)),
179 };
180 let mut records: Vec<NameRecord> = entries
181 .filter_map(Result::ok)
182 .map(|entry| entry.path())
183 .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
184 .filter_map(|path| std::fs::read_to_string(path).ok())
185 .filter_map(|text| serde_json::from_str(&text).ok())
186 .collect();
187 records.sort_by(|a, b| a.name.cmp(&b.name));
188 Ok(records)
189 }
190}
191
192fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
199 use std::io::Write as _;
200 let mut options = std::fs::OpenOptions::new();
201 options.write(true).create(true).truncate(true);
202 #[cfg(unix)]
203 {
204 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
205 options.mode(0o600);
206 if path.exists() {
207 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
208 }
209 }
210 let mut file = options.open(path)?;
211 file.write_all(bytes)?;
212 file.sync_all()
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use microvms_app::names::{default_state_root, resolve};
219 use microvms_app::region::Region;
220
221 #[test]
222 fn an_unset_home_uses_the_platform_default_for_the_registry() {
223 let store = FileNameStore::default_location(&|_| None);
224 #[cfg(unix)]
225 assert!(
226 store.dir().is_absolute(),
227 "the platform user home is absolute"
228 );
229 #[cfg(not(unix))]
230 assert!(
231 store.get("example").is_err(),
232 "an unavailable home refuses storage"
233 );
234 }
235
236 #[test]
237 fn an_unavailable_default_registry_refuses_operations() {
238 let store = FileNameStore::default_location(&|key| {
239 matches!(key, "HOME" | "USERPROFILE").then(|| "relative-home".to_string())
240 });
241 assert!(store.get("example").is_err());
242 assert!(store.list().is_err());
243 assert!(store.delete("example").is_err());
244 assert!(store.put(&record("example", "microvm-1")).is_err());
245 }
246
247 #[test]
248 fn explicit_state_settings_override_home_and_empty_settings_use_home() {
249 let home = TempDir::new("state-override");
250 let absolute = home.0.join("chosen-state");
251 for (setting, expected) in [
252 ("chosen-state".to_string(), PathBuf::from("chosen-state")),
253 (absolute.to_string_lossy().into_owned(), absolute),
254 (String::new(), home.0.join(".microvm").join("runs")),
255 ] {
256 let env = |key: &str| match key {
257 "MICROVM_STATE_DIR" => Some(setting.clone()),
258 "HOME" | "USERPROFILE" => Some(home.0.to_string_lossy().into_owned()),
259 _ => None,
260 };
261 assert_eq!(
262 FileNameStore::default_location(&env).dir(),
263 expected.join("names")
264 );
265 }
266 }
267
268 struct TempDir(PathBuf);
269
270 impl TempDir {
271 fn new(tag: &str) -> Self {
272 let unique = format!(
273 "microvms-names-{tag}-{}-{}",
274 std::process::id(),
275 std::time::SystemTime::now()
276 .duration_since(std::time::UNIX_EPOCH)
277 .map(|d| d.as_nanos())
278 .unwrap_or(0)
279 );
280 let path = std::env::temp_dir().join(unique);
281 std::fs::create_dir_all(&path).expect("temp dir");
282 Self(path)
283 }
284 }
285
286 impl Drop for TempDir {
287 fn drop(&mut self) {
288 let _ = std::fs::remove_dir_all(&self.0);
289 }
290 }
291
292 fn record(name: &str, id: &str) -> NameRecord {
293 NameRecord::new_at(
295 name,
296 id,
297 "https://vm.example",
298 "secret-token",
299 "us-east-1",
300 1_767_225_600,
301 )
302 .expect("ok")
303 }
304
305 #[test]
308 fn records_written_by_earlier_cli_versions_still_resolve() {
309 let dir = TempDir::new("compat");
310 let store = FileNameStore::in_state_root(&dir.0);
311 std::fs::create_dir_all(store.dir()).expect("dir");
312 std::fs::write(
313 store.path_of("old"),
314 r#"{
315 "name": "old",
316 "microvmId": "microvm-1",
317 "endpoint": "https://vm.example",
318 "agentToken": "tok",
319 "region": "us-east-1",
320 "at": 1789000000
321}"#,
322 )
323 .expect("write");
324 std::fs::write(
325 store.path_of("pinned"),
326 r#"{"name":"pinned","microvmId":"microvm-2","endpoint":"e","agentToken":"t",
327 "region":"us-west-2","at":1,"identityHostSeed":"c2VlZA==",
328 "identityVmPublicKey":"cHVi","egressPosture":"managed"}"#,
329 )
330 .expect("write");
331 let old = store.get("old").expect("reads").expect("present");
332 assert_eq!(
333 (old.microvm_id.as_str(), old.at, old.egress_posture),
334 ("microvm-1", 1_789_000_000, None)
335 );
336 let pinned = store.get("pinned").expect("reads").expect("present");
337 assert_eq!(pinned.identity_host_seed.as_deref(), Some("c2VlZA=="));
338 assert_eq!(pinned.egress_posture.as_deref(), Some("managed"));
339 let written = serde_json::to_value(&pinned).expect("json");
341 for key in [
342 "name",
343 "microvmId",
344 "endpoint",
345 "agentToken",
346 "region",
347 "at",
348 ] {
349 assert!(written.get(key).is_some(), "{key} missing from {written}");
350 }
351 }
352
353 #[test]
354 fn put_get_list_delete_and_release_round_trip() {
355 let dir = TempDir::new("crud");
356 let store = FileNameStore::in_state_root(&dir.0);
357 assert_eq!(store.get("ci").expect("ok"), None);
358 assert!(store.list().expect("ok").is_empty());
359 store.put(&record("ci", "microvm-a")).expect("put");
360 store.put(&record("alias", "microvm-a")).expect("put");
361 store.put(&record("other", "microvm-b")).expect("put");
362 assert_eq!(
363 store.get("ci").expect("ok").map(|r| r.microvm_id),
364 Some("microvm-a".to_string())
365 );
366 let names: Vec<_> = store
367 .list()
368 .expect("ok")
369 .into_iter()
370 .map(|r| r.name)
371 .collect();
372 assert_eq!(names, ["alias", "ci", "other"]);
373 assert_eq!(
374 store.release_by_vm("microvm-a").expect("ok"),
375 ["alias", "ci"]
376 );
377 assert!(store.delete("other").expect("ok"));
378 assert!(!store.delete("other").expect("ok"));
379 assert!(store.list().expect("ok").is_empty());
380 }
381
382 #[test]
383 fn a_torn_record_keeps_its_name_taken_without_hiding_the_others() {
384 let dir = TempDir::new("torn");
385 let store = FileNameStore::in_state_root(&dir.0);
386 store.put(&record("good", "microvm-a")).expect("put");
387 std::fs::write(store.path_of("torn"), "{\"name\": \"to").expect("write");
388 let error = store
389 .get("torn")
390 .expect_err("a torn file is not a free name");
391 assert_eq!(error.kind(), ErrorKind::Precondition);
392 assert!(error.to_string().contains("torn.json"), "{error}");
393 let names: Vec<_> = store
394 .list()
395 .expect("ok")
396 .into_iter()
397 .map(|r| r.name)
398 .collect();
399 assert_eq!(names, ["good"]);
400 }
401
402 #[test]
404 fn a_path_shaped_name_is_refused_before_it_reaches_the_filesystem() {
405 let dir = TempDir::new("illegal");
406 let store = FileNameStore::in_state_root(&dir.0);
407 assert!(store.get("../escape").is_err());
408 assert!(store.delete("../escape").is_err());
409 }
410
411 #[test]
412 fn resolve_refuses_a_missing_name_and_a_foreign_region() {
413 let dir = TempDir::new("resolve");
414 let store = FileNameStore::in_state_root(&dir.0);
415 let missing = resolve(&store, "ci", None).expect_err("no such name");
416 assert_eq!(missing.kind(), ErrorKind::Precondition);
417 assert!(missing.to_string().contains("names"), "{missing}");
418 store.put(&record("ci", "microvm-a")).expect("put");
419 assert_eq!(
420 resolve(&store, "ci", Some(&Region::US_EAST_1))
421 .expect("same region")
422 .microvm_id,
423 "microvm-a"
424 );
425 let foreign = resolve(&store, "ci", Some(&Region::US_WEST_2)).expect_err("other region");
426 assert_eq!(foreign.kind(), ErrorKind::InvalidArg);
427 assert!(!foreign.to_string().contains("secret-token"), "{foreign}");
428 }
429
430 #[cfg(unix)]
431 #[test]
432 fn a_record_file_is_owner_only_from_creation_and_after_overwrite() {
433 use std::os::unix::fs::PermissionsExt as _;
434 let dir = TempDir::new("mode");
435 let store = FileNameStore::in_state_root(&dir.0);
436 store.put(&record("ci", "microvm-a")).expect("put");
437 let mode = |p: &Path| std::fs::metadata(p).expect("meta").permissions().mode() & 0o777;
438 assert_eq!(mode(&store.path_of("ci")), 0o600);
439 std::fs::set_permissions(store.path_of("ci"), std::fs::Permissions::from_mode(0o644))
440 .expect("widen");
441 store.put(&record("ci", "microvm-b")).expect("overwrite");
442 assert_eq!(mode(&store.path_of("ci")), 0o600);
443 }
444
445 #[test]
446 fn the_default_location_is_the_clis() {
447 let home = TempDir::new("default-location");
448 let env = |key: &str| match key {
449 "HOME" => Some(home.0.to_string_lossy().into_owned()),
450 _ => None,
451 };
452 assert_eq!(
453 FileNameStore::default_location(&env).dir(),
454 home.0.join(".microvm").join("runs").join("names")
455 );
456 let pinned = |key: &str| (key == "MICROVM_STATE_DIR").then(|| "/state".to_string());
457 assert_eq!(default_state_root(&pinned), PathBuf::from("/state"));
458 }
459}