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, default_state_root, validate_name};
16
17/// The CLI's registry: one JSON file per name in a directory, owner-only on Unix.
18#[derive(Clone, Debug)]
19pub struct FileNameStore {
20    dir: PathBuf,
21}
22
23impl FileNameStore {
24    /// A store over the directory that holds the name files themselves.
25    pub fn new(dir: impl Into<PathBuf>) -> Self {
26        Self { dir: dir.into() }
27    }
28
29    /// The registry under `state_root/names` — where the CLI keeps it.
30    ///
31    /// A subdirectory rather than the state root because the run ledger's `*.json` glob
32    /// must never read a name record as a ledger.
33    pub fn in_state_root(state_root: &Path) -> Self {
34        Self::new(state_root.join("names"))
35    }
36
37    /// The registry the CLI would use in this environment.
38    pub fn default_location(env: &dyn Fn(&str) -> Option<String>) -> Self {
39        Self::in_state_root(&default_state_root(env))
40    }
41
42    /// The directory holding the name files.
43    pub fn dir(&self) -> &Path {
44        &self.dir
45    }
46
47    /// Where `name`'s record lives, whether or not one is registered.
48    pub fn path_of(&self, name: &str) -> PathBuf {
49        self.dir.join(format!("{name}.json"))
50    }
51
52    fn io(&self, action: &str, path: &Path, error: std::io::Error) -> Error {
53        Error::new(
54            ErrorKind::Precondition,
55            format!("could not {action} name record {}: {error}", path.display()),
56        )
57        .with_source(error)
58    }
59}
60
61impl NameStore for FileNameStore {
62    /// A file that exists but does not parse is an error, not a free name: a torn record
63    /// is what a killed write leaves, and treating it as free would let a second VM claim
64    /// a name whose first holder may still be billing.
65    fn get(&self, name: &str) -> Result<Option<NameRecord>, Error> {
66        validate_name(name).map_err(Error::invalid_arg)?;
67        let path = self.path_of(name);
68        let text = match std::fs::read_to_string(&path) {
69            Ok(text) => text,
70            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
71            Err(error) => return Err(self.io("read", &path, error)),
72        };
73        serde_json::from_str(&text).map(Some).map_err(|_| {
74            Error::new(
75                ErrorKind::Precondition,
76                format!(
77                    "name record {} is unreadable; the name stays taken until you inspect \
78                     or delete that file",
79                    path.display()
80                ),
81            )
82        })
83    }
84
85    fn put(&self, record: &NameRecord) -> Result<(), Error> {
86        validate_name(&record.name).map_err(Error::invalid_arg)?;
87        std::fs::create_dir_all(&self.dir).map_err(|error| self.io("create", &self.dir, error))?;
88        let path = self.path_of(&record.name);
89        let text = serde_json::to_string_pretty(record)
90            .map_err(|_| Error::new(ErrorKind::Unexpected, "a name record failed to serialize"))?;
91        write_private(&path, text.as_bytes()).map_err(|error| self.io("write", &path, error))
92    }
93
94    fn delete(&self, name: &str) -> Result<bool, Error> {
95        validate_name(name).map_err(Error::invalid_arg)?;
96        let path = self.path_of(name);
97        match std::fs::remove_file(&path) {
98            Ok(()) => Ok(true),
99            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
100            Err(error) => Err(self.io("delete", &path, error)),
101        }
102    }
103
104    fn describe(&self) -> String {
105        self.dir.display().to_string()
106    }
107
108    /// Skips files that are not records: a torn file is reported by [`NameStore::get`]
109    /// for its own name, and must not make every other name unlistable.
110    fn list(&self) -> Result<Vec<NameRecord>, Error> {
111        let entries = match std::fs::read_dir(&self.dir) {
112            Ok(entries) => entries,
113            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
114            Err(error) => return Err(self.io("list", &self.dir, error)),
115        };
116        let mut records: Vec<NameRecord> = entries
117            .filter_map(Result::ok)
118            .map(|entry| entry.path())
119            .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
120            .filter_map(|path| std::fs::read_to_string(path).ok())
121            .filter_map(|text| serde_json::from_str(&text).ok())
122            .collect();
123        records.sort_by(|a, b| a.name.cmp(&b.name));
124        Ok(records)
125    }
126}
127
128/// Writes `bytes` so no other user can read them at any point.
129///
130/// On Unix the file is created 0600 rather than written and then narrowed, which would
131/// leave the token readable under the process umask for the length of the write. An
132/// existing file is narrowed before it is overwritten. Elsewhere the state directory keeps
133/// the profile's own ACLs.
134fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
135    use std::io::Write as _;
136    let mut options = std::fs::OpenOptions::new();
137    options.write(true).create(true).truncate(true);
138    #[cfg(unix)]
139    {
140        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
141        options.mode(0o600);
142        if path.exists() {
143            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
144        }
145    }
146    let mut file = options.open(path)?;
147    file.write_all(bytes)?;
148    file.sync_all()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use microvms_app::names::resolve;
155    use microvms_app::region::Region;
156
157    struct TempDir(PathBuf);
158
159    impl TempDir {
160        fn new(tag: &str) -> Self {
161            let unique = format!(
162                "microvms-names-{tag}-{}-{}",
163                std::process::id(),
164                std::time::SystemTime::now()
165                    .duration_since(std::time::UNIX_EPOCH)
166                    .map(|d| d.as_nanos())
167                    .unwrap_or(0)
168            );
169            let path = std::env::temp_dir().join(unique);
170            std::fs::create_dir_all(&path).expect("temp dir");
171            Self(path)
172        }
173    }
174
175    impl Drop for TempDir {
176        fn drop(&mut self) {
177            let _ = std::fs::remove_dir_all(&self.0);
178        }
179    }
180
181    fn record(name: &str, id: &str) -> NameRecord {
182        // 2026-01-01T00:00:00Z: the store keeps whatever time the record carries.
183        NameRecord::new_at(
184            name,
185            id,
186            "https://vm.example",
187            "secret-token",
188            "us-east-1",
189            1_767_225_600,
190        )
191        .expect("ok")
192    }
193
194    /// A record exactly as the CLI has written it since names shipped: pretty JSON,
195    /// camelCase, optional fields present in one and absent in the other.
196    #[test]
197    fn records_written_by_earlier_cli_versions_still_resolve() {
198        let dir = TempDir::new("compat");
199        let store = FileNameStore::in_state_root(&dir.0);
200        std::fs::create_dir_all(store.dir()).expect("dir");
201        std::fs::write(
202            store.path_of("old"),
203            r#"{
204  "name": "old",
205  "microvmId": "microvm-1",
206  "endpoint": "https://vm.example",
207  "agentToken": "tok",
208  "region": "us-east-1",
209  "at": 1789000000
210}"#,
211        )
212        .expect("write");
213        std::fs::write(
214            store.path_of("pinned"),
215            r#"{"name":"pinned","microvmId":"microvm-2","endpoint":"e","agentToken":"t",
216                "region":"us-west-2","at":1,"identityHostSeed":"c2VlZA==",
217                "identityVmPublicKey":"cHVi","egressPosture":"managed"}"#,
218        )
219        .expect("write");
220        let old = store.get("old").expect("reads").expect("present");
221        assert_eq!(
222            (old.microvm_id.as_str(), old.at, old.egress_posture),
223            ("microvm-1", 1_789_000_000, None)
224        );
225        let pinned = store.get("pinned").expect("reads").expect("present");
226        assert_eq!(pinned.identity_host_seed.as_deref(), Some("c2VlZA=="));
227        assert_eq!(pinned.egress_posture.as_deref(), Some("managed"));
228        // And what this version writes, an earlier reader can read: the same keys.
229        let written = serde_json::to_value(&pinned).expect("json");
230        for key in [
231            "name",
232            "microvmId",
233            "endpoint",
234            "agentToken",
235            "region",
236            "at",
237        ] {
238            assert!(written.get(key).is_some(), "{key} missing from {written}");
239        }
240    }
241
242    #[test]
243    fn put_get_list_delete_and_release_round_trip() {
244        let dir = TempDir::new("crud");
245        let store = FileNameStore::in_state_root(&dir.0);
246        assert_eq!(store.get("ci").expect("ok"), None);
247        assert!(store.list().expect("ok").is_empty());
248        store.put(&record("ci", "microvm-a")).expect("put");
249        store.put(&record("alias", "microvm-a")).expect("put");
250        store.put(&record("other", "microvm-b")).expect("put");
251        assert_eq!(
252            store.get("ci").expect("ok").map(|r| r.microvm_id),
253            Some("microvm-a".to_string())
254        );
255        let names: Vec<_> = store
256            .list()
257            .expect("ok")
258            .into_iter()
259            .map(|r| r.name)
260            .collect();
261        assert_eq!(names, ["alias", "ci", "other"]);
262        assert_eq!(
263            store.release_by_vm("microvm-a").expect("ok"),
264            ["alias", "ci"]
265        );
266        assert!(store.delete("other").expect("ok"));
267        assert!(!store.delete("other").expect("ok"));
268        assert!(store.list().expect("ok").is_empty());
269    }
270
271    #[test]
272    fn a_torn_record_keeps_its_name_taken_without_hiding_the_others() {
273        let dir = TempDir::new("torn");
274        let store = FileNameStore::in_state_root(&dir.0);
275        store.put(&record("good", "microvm-a")).expect("put");
276        std::fs::write(store.path_of("torn"), "{\"name\": \"to").expect("write");
277        let error = store
278            .get("torn")
279            .expect_err("a torn file is not a free name");
280        assert_eq!(error.kind(), ErrorKind::Precondition);
281        assert!(error.to_string().contains("torn.json"), "{error}");
282        let names: Vec<_> = store
283            .list()
284            .expect("ok")
285            .into_iter()
286            .map(|r| r.name)
287            .collect();
288        assert_eq!(names, ["good"]);
289    }
290
291    /// A path-shaped name must never become a path outside the registry.
292    #[test]
293    fn a_path_shaped_name_is_refused_before_it_reaches_the_filesystem() {
294        let dir = TempDir::new("illegal");
295        let store = FileNameStore::in_state_root(&dir.0);
296        assert!(store.get("../escape").is_err());
297        assert!(store.delete("../escape").is_err());
298    }
299
300    #[test]
301    fn resolve_refuses_a_missing_name_and_a_foreign_region() {
302        let dir = TempDir::new("resolve");
303        let store = FileNameStore::in_state_root(&dir.0);
304        let missing = resolve(&store, "ci", None).expect_err("no such name");
305        assert_eq!(missing.kind(), ErrorKind::Precondition);
306        assert!(missing.to_string().contains("names"), "{missing}");
307        store.put(&record("ci", "microvm-a")).expect("put");
308        assert_eq!(
309            resolve(&store, "ci", Some(&Region::UsEast1))
310                .expect("same region")
311                .microvm_id,
312            "microvm-a"
313        );
314        let foreign = resolve(&store, "ci", Some(&Region::UsWest2)).expect_err("other region");
315        assert_eq!(foreign.kind(), ErrorKind::InvalidArg);
316        assert!(!foreign.to_string().contains("secret-token"), "{foreign}");
317    }
318
319    #[cfg(unix)]
320    #[test]
321    fn a_record_file_is_owner_only_from_creation_and_after_overwrite() {
322        use std::os::unix::fs::PermissionsExt as _;
323        let dir = TempDir::new("mode");
324        let store = FileNameStore::in_state_root(&dir.0);
325        store.put(&record("ci", "microvm-a")).expect("put");
326        let mode = |p: &Path| std::fs::metadata(p).expect("meta").permissions().mode() & 0o777;
327        assert_eq!(mode(&store.path_of("ci")), 0o600);
328        std::fs::set_permissions(store.path_of("ci"), std::fs::Permissions::from_mode(0o644))
329            .expect("widen");
330        store.put(&record("ci", "microvm-b")).expect("overwrite");
331        assert_eq!(mode(&store.path_of("ci")), 0o600);
332    }
333
334    #[test]
335    fn the_default_location_is_the_clis() {
336        let env = |key: &str| match key {
337            "HOME" => Some("/home/u".to_string()),
338            _ => None,
339        };
340        assert_eq!(
341            FileNameStore::default_location(&env).dir(),
342            Path::new("/home/u/.microvm/runs/names")
343        );
344        let pinned = |key: &str| (key == "MICROVM_STATE_DIR").then(|| "/state".to_string());
345        assert_eq!(default_state_root(&pinned), PathBuf::from("/state"));
346    }
347}