Skip to main content

microvms_edges/
names.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The CLI's name registry: one owner-only JSON file per name under `<state dir>/names/`.
3//!
4//! The record, its rules and the [`NameStore`] port are `microvms_app::names`. [`FileNameStore`]
5//! is the implementation that reads and writes files, byte-compatible with every record the CLI
6//! has written.
7//!
8//! **A record holds a secret.** The agent token is a bearer credential for the VM, and the
9//! optional identity seed can impersonate the launching host to it. The files are owner-only
10//! from creation, and neither value appears in `Debug` output or error messages.
11
12use std::path::{Path, PathBuf};
13
14use microvms_app::error::{Error, ErrorKind};
15use microvms_app::names::{NameRecord, NameStore, validate_name};
16
17/// Resolve the shared state root from an explicit setting or the user's absolute home.
18///
19/// A default never depends on the working directory. Explicit state settings may be
20/// relative because the caller deliberately selected their location.
21pub fn state_root(env: &dyn Fn(&str) -> Option<String>) -> Result<PathBuf, Error> {
22    resolve_state_root(env, || {
23        #[cfg(unix)]
24        {
25            // The Unix implementation consults the passwd database when HOME is unset.
26            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/// The CLI's registry: one JSON file per name in a directory, owner-only on Unix.
58#[derive(Clone, Debug)]
59pub struct FileNameStore {
60    dir: PathBuf,
61    unavailable: Option<String>,
62}
63
64impl FileNameStore {
65    /// A store over the directory that holds the name files themselves.
66    pub fn new(dir: impl Into<PathBuf>) -> Self {
67        Self {
68            dir: dir.into(),
69            unavailable: None,
70        }
71    }
72
73    /// The registry under `state_root/names` — where the CLI keeps it.
74    ///
75    /// A subdirectory rather than the state root because the run ledger's `*.json` glob
76    /// must never read a name record as a ledger.
77    pub fn in_state_root(state_root: &Path) -> Self {
78        Self::new(state_root.join("names"))
79    }
80
81    /// The registry the CLI would use in this environment.
82    ///
83    /// If no absolute home can be resolved, the returned store refuses every storage
84    /// operation with a precondition error and its directory is empty.
85    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    /// The directory holding the name files.
96    pub fn dir(&self) -> &Path {
97        &self.dir
98    }
99
100    /// Where `name`'s record lives, whether or not one is registered.
101    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    /// A file that exists but does not parse is an error, not a free name: a torn record
123    /// is what a killed write leaves, and treating it as free would let a second VM claim
124    /// a name whose first holder may still be billing.
125    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    /// Skips files that are not records: a torn file is reported by [`NameStore::get`]
172    /// for its own name, and must not make every other name unlistable.
173    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
192/// Writes `bytes` so no other user can read them at any point.
193///
194/// On Unix the file is created 0600 rather than written and then narrowed, which would
195/// leave the token readable under the process umask for the length of the write. An
196/// existing file is narrowed before it is overwritten. Elsewhere the state directory keeps
197/// the profile's own ACLs.
198fn 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        // 2026-01-01T00:00:00Z: the store keeps whatever time the record carries.
294        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    /// A record exactly as the CLI has written it since names shipped: pretty JSON,
306    /// camelCase, optional fields present in one and absent in the other.
307    #[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        // And what this version writes, an earlier reader can read: the same keys.
340        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    /// A path-shaped name must never become a path outside the registry.
403    #[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::UsEast1))
421                .expect("same region")
422                .microvm_id,
423            "microvm-a"
424        );
425        let foreign = resolve(&store, "ci", Some(&Region::UsWest2)).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}