Skip to main content

microvms_core/
names.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Named MicroVMs: records that let a later process find a VM by name and adopt it.
3//!
4//! The platform offers no lookup of its own. `RunMicrovm` takes no tags and tagging a
5//! running MicroVM fails (docs/PLATFORM.md, "Tagging works on images and not on
6//! MicroVMs"), so a name has to live beside the caller. A [`NameRecord`] carries
7//! everything [`crate::sandbox::Sandbox::adopt`] needs — id, endpoint, agent token,
8//! region — so a name replaces the whole triple rather than just the id.
9//!
10//! Storage is a [`NameStore`]. [`FileNameStore`] is the CLI's registry, one owner-only
11//! JSON file per name under `<state dir>/names/`, byte-compatible with every record the
12//! CLI has written. A caller with its own database implements [`NameStore`] or keeps
13//! [`NameRecord`]'s JSON form wherever it likes.
14//!
15//! **A record holds a secret.** The agent token is a bearer credential for the VM, and
16//! the optional identity seed can impersonate the launching host to it. Keep records in
17//! private storage; neither value appears in `Debug` output or error messages.
18
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{Error, ErrorKind};
24use crate::region::Region;
25
26/// A VM name's shape: ASCII letters, digits, `-`, `_`, at most 128 bytes, and never a
27/// MicroVM id prefix.
28///
29/// The charset is the image-name pattern (`[a-zA-Z0-9-_]+`). It makes the CLI's
30/// identifier resolution total — an identifier starting with `microvm-` can only be an id,
31/// because a legal name is refused that prefix, and an ARN cannot match because `:` is
32/// outside the set — and it makes every name a safe file name.
33pub fn validate_name(name: &str) -> Result<(), String> {
34    if name.is_empty() {
35        return Err("a VM name cannot be empty".to_string());
36    }
37    if name.len() > 128 {
38        return Err(format!(
39            "a VM name is at most 128 bytes; this one is {}",
40            name.len()
41        ));
42    }
43    // Both spellings: `microvm-` is the id prefix the real service answers (measured
44    // 2026-08-28, first live run of names — the fakes' `mvm-` fixture shape let a
45    // passthrough keyed on `mvm-` alone pass every scripted test and fail against AWS),
46    // and `mvm-` stays refused because it is the fixture shape every scripted body uses.
47    if name.starts_with("microvm-") || name.starts_with("mvm-") {
48        return Err(format!(
49            "{name:?} starts with a MicroVM id prefix — a name shaped like an id would make \
50             `microvm suspend <identifier>` ambiguous about which VM it addresses"
51        ));
52    }
53    if let Some(bad) = name
54        .chars()
55        .find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
56    {
57        return Err(format!(
58            "{bad:?} is not a legal VM-name character: names take ASCII letters, digits, `-` \
59             and `_`, the image-name pattern"
60        ));
61    }
62    Ok(())
63}
64
65/// One kept VM's name, and everything an adopt needs to address it.
66///
67/// `camelCase` on the wire: this is the CLI registry's on-disk format, read by later
68/// versions, so fields are only ever added, and optional ones default when absent.
69#[derive(Clone, Deserialize, PartialEq, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct NameRecord {
72    pub name: String,
73    pub microvm_id: String,
74    pub endpoint: String,
75    /// The launch's agent token: a bearer credential for the VM.
76    pub agent_token: String,
77    pub region: String,
78    /// Seconds since the epoch when the name was registered.
79    pub at: u64,
80    /// The launching host's identity secret, base64, when `run --identity` generated one.
81    ///
82    /// Persisted for the reason the agent token is: a later `tunnel --verify-identity`
83    /// needs it. It raises what a stolen record can do from "call the VM" to "call the VM
84    /// and impersonate the launching host to it", which is the same trust domain.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub identity_host_seed: Option<String>,
87    /// The VM's public key, base64 — the pin `--verify-identity` checks the far end against.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub identity_vm_public_key: Option<String>,
90    /// The egress posture label of the launch this name was registered for, when the
91    /// registering command launched the VM. `None` means unknown, never "open".
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub egress_posture: Option<String>,
94}
95
96impl NameRecord {
97    /// A record for a VM this process can already address, stamped with the current time.
98    ///
99    /// Refuses an illegal name or an empty id, endpoint, or token: a record missing any of
100    /// them resolves to a VM nobody can adopt.
101    pub fn new(
102        name: impl Into<String>,
103        microvm_id: impl Into<String>,
104        endpoint: impl Into<String>,
105        agent_token: impl Into<String>,
106        region: impl Into<String>,
107    ) -> Result<Self, Error> {
108        let record = Self {
109            name: name.into(),
110            microvm_id: microvm_id.into(),
111            endpoint: endpoint.into(),
112            agent_token: agent_token.into(),
113            region: region.into(),
114            at: std::time::SystemTime::now()
115                .duration_since(std::time::UNIX_EPOCH)
116                .map(|since| since.as_secs())
117                .unwrap_or(0),
118            identity_host_seed: None,
119            identity_vm_public_key: None,
120            egress_posture: None,
121        };
122        record.check()?;
123        Ok(record)
124    }
125
126    /// The record as JSON, secrets included — the form to store privately.
127    pub fn to_json(&self) -> serde_json::Value {
128        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
129    }
130
131    /// A record from its JSON form, checked the way [`NameRecord::new`] checks.
132    pub fn from_json(value: serde_json::Value) -> Result<Self, Error> {
133        // The serde message can quote the offending value, which may be the token itself.
134        let record: Self = serde_json::from_value(value).map_err(|_| {
135            Error::invalid_arg(
136                "not a name record: it needs string name, microvmId, endpoint, agentToken, \
137                 and region, and a numeric at",
138            )
139        })?;
140        record.check()?;
141        Ok(record)
142    }
143
144    /// The region the VM runs in. A record's region is one this client wrote, so a name it
145    /// no longer lists is taken as written rather than refused.
146    pub fn region(&self) -> Region {
147        Region::unlisted(self.region.as_str())
148    }
149
150    fn check(&self) -> Result<(), Error> {
151        validate_name(&self.name).map_err(Error::invalid_arg)?;
152        for (field, value) in [
153            ("microvmId", &self.microvm_id),
154            ("endpoint", &self.endpoint),
155            ("agentToken", &self.agent_token),
156            ("region", &self.region),
157        ] {
158            if value.is_empty() {
159                return Err(Error::invalid_arg(format!(
160                    "name record {:?} has an empty {field}; a record must carry everything an \
161                     adopt needs",
162                    self.name
163                )));
164            }
165        }
166        Ok(())
167    }
168}
169
170/// Redacts the agent token and the identity seed: this type reaches logs through errors
171/// and test output, and both values are credentials.
172impl std::fmt::Debug for NameRecord {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.debug_struct("NameRecord")
175            .field("name", &self.name)
176            .field("microvm_id", &self.microvm_id)
177            .field("endpoint", &self.endpoint)
178            .field("agent_token", &"<redacted>")
179            .field("region", &self.region)
180            .field("at", &self.at)
181            .field(
182                "identity_host_seed",
183                &self.identity_host_seed.as_ref().map(|_| "<redacted>"),
184            )
185            .field("identity_vm_public_key", &self.identity_vm_public_key)
186            .field("egress_posture", &self.egress_posture)
187            .finish()
188    }
189}
190
191/// Where names are kept. Implement it to keep records in your own database.
192pub trait NameStore: Send + Sync {
193    /// The record registered under `name`, or `None` when the name is free.
194    fn get(&self, name: &str) -> Result<Option<NameRecord>, Error>;
195    /// Writes `record` under its name, replacing any earlier record.
196    fn put(&self, record: &NameRecord) -> Result<(), Error>;
197    /// Removes `name`, answering whether a record was there.
198    fn delete(&self, name: &str) -> Result<bool, Error>;
199    /// Every readable record, sorted by name.
200    fn list(&self) -> Result<Vec<NameRecord>, Error>;
201
202    /// Where this store keeps names, for error messages.
203    fn describe(&self) -> String {
204        "the name store".to_string()
205    }
206
207    /// Removes every name registered to `microvm_id` and returns them, sorted.
208    ///
209    /// Every match rather than the first: one VM can carry two names in one registry
210    /// (measured live 2026-09-02: terminating through one alias left the other pointing
211    /// at a VM that no longer existed).
212    fn release_by_vm(&self, microvm_id: &str) -> Result<Vec<String>, Error> {
213        let mut released = Vec::new();
214        for record in self.list()? {
215            if record.microvm_id == microvm_id && self.delete(&record.name)? {
216                released.push(record.name);
217            }
218        }
219        released.sort();
220        Ok(released)
221    }
222}
223
224/// The record for `name`, refused when it is missing or registered in another region.
225///
226/// The region check is the guard: an id from one region addresses nothing in another, and
227/// adopting it would fail at the first control-plane call with a not-found that says
228/// nothing about the name.
229pub fn resolve(
230    store: &dyn NameStore,
231    name: &str,
232    expected_region: Option<&Region>,
233) -> Result<NameRecord, Error> {
234    let record = store.get(name)?.ok_or_else(|| {
235        Error::new(
236            ErrorKind::Precondition,
237            format!(
238                "no VM is named {name:?} in {}; register one first, or adopt by id",
239                store.describe()
240            ),
241        )
242    })?;
243    if let Some(expected) = expected_region
244        && expected.as_str() != record.region
245    {
246        return Err(Error::invalid_arg(format!(
247            "{name:?} was registered in {}, not {}",
248            record.region,
249            expected.as_str()
250        )));
251    }
252    Ok(record)
253}
254
255/// The state directory the CLI uses: `$MICROVM_STATE_DIR`, else `~/.microvm/runs`.
256pub fn default_state_root(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
257    if let Some(dir) = env("MICROVM_STATE_DIR") {
258        return PathBuf::from(dir);
259    }
260    let home = env("HOME").unwrap_or_else(|| ".".to_string());
261    PathBuf::from(home).join(".microvm").join("runs")
262}
263
264/// The CLI's registry: one JSON file per name in a directory, owner-only on Unix.
265#[derive(Clone, Debug)]
266pub struct FileNameStore {
267    dir: PathBuf,
268}
269
270impl FileNameStore {
271    /// A store over the directory that holds the name files themselves.
272    pub fn new(dir: impl Into<PathBuf>) -> Self {
273        Self { dir: dir.into() }
274    }
275
276    /// The registry under `state_root/names` — where the CLI keeps it.
277    ///
278    /// A subdirectory rather than the state root because the run ledger's `*.json` glob
279    /// must never read a name record as a ledger.
280    pub fn in_state_root(state_root: &Path) -> Self {
281        Self::new(state_root.join("names"))
282    }
283
284    /// The registry the CLI would use in this environment.
285    pub fn default_location(env: &dyn Fn(&str) -> Option<String>) -> Self {
286        Self::in_state_root(&default_state_root(env))
287    }
288
289    /// The directory holding the name files.
290    pub fn dir(&self) -> &Path {
291        &self.dir
292    }
293
294    /// Where `name`'s record lives, whether or not one is registered.
295    pub fn path_of(&self, name: &str) -> PathBuf {
296        self.dir.join(format!("{name}.json"))
297    }
298
299    fn io(&self, action: &str, path: &Path, error: std::io::Error) -> Error {
300        Error::new(
301            ErrorKind::Precondition,
302            format!("could not {action} name record {}: {error}", path.display()),
303        )
304        .with_source(error)
305    }
306}
307
308impl NameStore for FileNameStore {
309    /// A file that exists but does not parse is an error, not a free name: a torn record
310    /// is what a killed write leaves, and treating it as free would let a second VM claim
311    /// a name whose first holder may still be billing.
312    fn get(&self, name: &str) -> Result<Option<NameRecord>, Error> {
313        validate_name(name).map_err(Error::invalid_arg)?;
314        let path = self.path_of(name);
315        let text = match std::fs::read_to_string(&path) {
316            Ok(text) => text,
317            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
318            Err(error) => return Err(self.io("read", &path, error)),
319        };
320        serde_json::from_str(&text).map(Some).map_err(|_| {
321            Error::new(
322                ErrorKind::Precondition,
323                format!(
324                    "name record {} is unreadable; the name stays taken until you inspect \
325                     or delete that file",
326                    path.display()
327                ),
328            )
329        })
330    }
331
332    fn put(&self, record: &NameRecord) -> Result<(), Error> {
333        validate_name(&record.name).map_err(Error::invalid_arg)?;
334        std::fs::create_dir_all(&self.dir).map_err(|error| self.io("create", &self.dir, error))?;
335        let path = self.path_of(&record.name);
336        let text = serde_json::to_string_pretty(record)
337            .map_err(|_| Error::new(ErrorKind::Unexpected, "a name record failed to serialize"))?;
338        write_private(&path, text.as_bytes()).map_err(|error| self.io("write", &path, error))
339    }
340
341    fn delete(&self, name: &str) -> Result<bool, Error> {
342        validate_name(name).map_err(Error::invalid_arg)?;
343        let path = self.path_of(name);
344        match std::fs::remove_file(&path) {
345            Ok(()) => Ok(true),
346            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
347            Err(error) => Err(self.io("delete", &path, error)),
348        }
349    }
350
351    fn describe(&self) -> String {
352        self.dir.display().to_string()
353    }
354
355    /// Skips files that are not records: a torn file is reported by [`NameStore::get`]
356    /// for its own name, and must not make every other name unlistable.
357    fn list(&self) -> Result<Vec<NameRecord>, Error> {
358        let entries = match std::fs::read_dir(&self.dir) {
359            Ok(entries) => entries,
360            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
361            Err(error) => return Err(self.io("list", &self.dir, error)),
362        };
363        let mut records: Vec<NameRecord> = entries
364            .filter_map(Result::ok)
365            .map(|entry| entry.path())
366            .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
367            .filter_map(|path| std::fs::read_to_string(path).ok())
368            .filter_map(|text| serde_json::from_str(&text).ok())
369            .collect();
370        records.sort_by(|a, b| a.name.cmp(&b.name));
371        Ok(records)
372    }
373}
374
375/// Writes `bytes` so no other user can read them at any point.
376///
377/// On Unix the file is created 0600 rather than written and then narrowed, which would
378/// leave the token readable under the process umask for the length of the write. An
379/// existing file is narrowed before it is overwritten. Elsewhere the state directory keeps
380/// the profile's own ACLs.
381fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
382    use std::io::Write as _;
383    let mut options = std::fs::OpenOptions::new();
384    options.write(true).create(true).truncate(true);
385    #[cfg(unix)]
386    {
387        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
388        options.mode(0o600);
389        if path.exists() {
390            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
391        }
392    }
393    let mut file = options.open(path)?;
394    file.write_all(bytes)?;
395    file.sync_all()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    struct TempDir(PathBuf);
403
404    impl TempDir {
405        fn new(tag: &str) -> Self {
406            let unique = format!(
407                "microvms-names-{tag}-{}-{}",
408                std::process::id(),
409                std::time::SystemTime::now()
410                    .duration_since(std::time::UNIX_EPOCH)
411                    .map(|d| d.as_nanos())
412                    .unwrap_or(0)
413            );
414            let path = std::env::temp_dir().join(unique);
415            std::fs::create_dir_all(&path).expect("temp dir");
416            Self(path)
417        }
418    }
419
420    impl Drop for TempDir {
421        fn drop(&mut self) {
422            let _ = std::fs::remove_dir_all(&self.0);
423        }
424    }
425
426    fn record(name: &str, id: &str) -> NameRecord {
427        NameRecord::new(name, id, "https://vm.example", "secret-token", "us-east-1").expect("ok")
428    }
429
430    /// A record exactly as the CLI has written it since names shipped: pretty JSON,
431    /// camelCase, optional fields present in one and absent in the other.
432    #[test]
433    fn records_written_by_earlier_cli_versions_still_resolve() {
434        let dir = TempDir::new("compat");
435        let store = FileNameStore::in_state_root(&dir.0);
436        std::fs::create_dir_all(store.dir()).expect("dir");
437        std::fs::write(
438            store.path_of("old"),
439            r#"{
440  "name": "old",
441  "microvmId": "microvm-1",
442  "endpoint": "https://vm.example",
443  "agentToken": "tok",
444  "region": "us-east-1",
445  "at": 1789000000
446}"#,
447        )
448        .expect("write");
449        std::fs::write(
450            store.path_of("pinned"),
451            r#"{"name":"pinned","microvmId":"microvm-2","endpoint":"e","agentToken":"t",
452                "region":"us-west-2","at":1,"identityHostSeed":"c2VlZA==",
453                "identityVmPublicKey":"cHVi","egressPosture":"managed"}"#,
454        )
455        .expect("write");
456        let old = store.get("old").expect("reads").expect("present");
457        assert_eq!(
458            (old.microvm_id.as_str(), old.at, old.egress_posture),
459            ("microvm-1", 1_789_000_000, None)
460        );
461        let pinned = store.get("pinned").expect("reads").expect("present");
462        assert_eq!(pinned.identity_host_seed.as_deref(), Some("c2VlZA=="));
463        assert_eq!(pinned.egress_posture.as_deref(), Some("managed"));
464        // And what this version writes, an earlier reader can read: the same keys.
465        let written = serde_json::to_value(&pinned).expect("json");
466        for key in [
467            "name",
468            "microvmId",
469            "endpoint",
470            "agentToken",
471            "region",
472            "at",
473        ] {
474            assert!(written.get(key).is_some(), "{key} missing from {written}");
475        }
476    }
477
478    #[test]
479    fn put_get_list_delete_and_release_round_trip() {
480        let dir = TempDir::new("crud");
481        let store = FileNameStore::in_state_root(&dir.0);
482        assert_eq!(store.get("ci").expect("ok"), None);
483        assert!(store.list().expect("ok").is_empty());
484        store.put(&record("ci", "microvm-a")).expect("put");
485        store.put(&record("alias", "microvm-a")).expect("put");
486        store.put(&record("other", "microvm-b")).expect("put");
487        assert_eq!(
488            store.get("ci").expect("ok").map(|r| r.microvm_id),
489            Some("microvm-a".to_string())
490        );
491        let names: Vec<_> = store
492            .list()
493            .expect("ok")
494            .into_iter()
495            .map(|r| r.name)
496            .collect();
497        assert_eq!(names, ["alias", "ci", "other"]);
498        assert_eq!(
499            store.release_by_vm("microvm-a").expect("ok"),
500            ["alias", "ci"]
501        );
502        assert!(store.delete("other").expect("ok"));
503        assert!(!store.delete("other").expect("ok"));
504        assert!(store.list().expect("ok").is_empty());
505    }
506
507    #[test]
508    fn a_torn_record_keeps_its_name_taken_without_hiding_the_others() {
509        let dir = TempDir::new("torn");
510        let store = FileNameStore::in_state_root(&dir.0);
511        store.put(&record("good", "microvm-a")).expect("put");
512        std::fs::write(store.path_of("torn"), "{\"name\": \"to").expect("write");
513        let error = store
514            .get("torn")
515            .expect_err("a torn file is not a free name");
516        assert_eq!(error.kind(), ErrorKind::Precondition);
517        assert!(error.to_string().contains("torn.json"), "{error}");
518        let names: Vec<_> = store
519            .list()
520            .expect("ok")
521            .into_iter()
522            .map(|r| r.name)
523            .collect();
524        assert_eq!(names, ["good"]);
525    }
526
527    #[test]
528    fn neither_debug_nor_a_rejection_prints_a_secret() {
529        let mut canaried = record("ci", "microvm-a");
530        canaried.agent_token = "tok-CANARY".into();
531        canaried.identity_host_seed = Some("seed-CANARY".into());
532        let debug = format!("{canaried:?}");
533        assert!(!debug.contains("CANARY"), "{debug}");
534        assert!(debug.contains("<redacted>"), "{debug}");
535        let error = NameRecord::from_json(serde_json::json!({
536            "name": "ci", "microvmId": "microvm-a", "endpoint": "e",
537            "agentToken": "tok-CANARY", "region": "us-east-1", "at": "not-a-number"
538        }))
539        .expect_err("at must be numeric");
540        assert!(!error.to_string().contains("CANARY"), "{error}");
541    }
542
543    #[test]
544    fn records_refuse_illegal_names_and_missing_fields() {
545        assert!(NameRecord::new("microvm-x", "id", "e", "t", "us-east-1").is_err());
546        assert!(NameRecord::new("has space", "id", "e", "t", "us-east-1").is_err());
547        let error = NameRecord::new("ci", "id", "e", "", "us-east-1").expect_err("no token");
548        assert!(error.to_string().contains("agentToken"), "{error}");
549        let dir = TempDir::new("illegal");
550        let store = FileNameStore::in_state_root(&dir.0);
551        // A path-shaped name must never become a path outside the registry.
552        assert!(store.get("../escape").is_err());
553        assert!(store.delete("../escape").is_err());
554        let round = NameRecord::from_json(record("ci", "microvm-a").to_json()).expect("ok");
555        assert_eq!(round, record("ci", "microvm-a").clone_with_at(round.at));
556    }
557
558    impl NameRecord {
559        fn clone_with_at(mut self, at: u64) -> Self {
560            self.at = at;
561            self
562        }
563    }
564
565    #[test]
566    fn resolve_refuses_a_missing_name_and_a_foreign_region() {
567        let dir = TempDir::new("resolve");
568        let store = FileNameStore::in_state_root(&dir.0);
569        let missing = resolve(&store, "ci", None).expect_err("no such name");
570        assert_eq!(missing.kind(), ErrorKind::Precondition);
571        assert!(missing.to_string().contains("names"), "{missing}");
572        store.put(&record("ci", "microvm-a")).expect("put");
573        assert_eq!(
574            resolve(&store, "ci", Some(&Region::UsEast1))
575                .expect("same region")
576                .microvm_id,
577            "microvm-a"
578        );
579        let foreign = resolve(&store, "ci", Some(&Region::UsWest2)).expect_err("other region");
580        assert_eq!(foreign.kind(), ErrorKind::InvalidArg);
581        assert!(!foreign.to_string().contains("secret-token"), "{foreign}");
582    }
583
584    #[cfg(unix)]
585    #[test]
586    fn a_record_file_is_owner_only_from_creation_and_after_overwrite() {
587        use std::os::unix::fs::PermissionsExt as _;
588        let dir = TempDir::new("mode");
589        let store = FileNameStore::in_state_root(&dir.0);
590        store.put(&record("ci", "microvm-a")).expect("put");
591        let mode = |p: &Path| std::fs::metadata(p).expect("meta").permissions().mode() & 0o777;
592        assert_eq!(mode(&store.path_of("ci")), 0o600);
593        std::fs::set_permissions(store.path_of("ci"), std::fs::Permissions::from_mode(0o644))
594            .expect("widen");
595        store.put(&record("ci", "microvm-b")).expect("overwrite");
596        assert_eq!(mode(&store.path_of("ci")), 0o600);
597    }
598
599    #[test]
600    fn the_default_location_is_the_clis() {
601        let env = |key: &str| match key {
602            "HOME" => Some("/home/u".to_string()),
603            _ => None,
604        };
605        assert_eq!(
606            FileNameStore::default_location(&env).dir(),
607            Path::new("/home/u/.microvm/runs/names")
608        );
609        let pinned = |key: &str| (key == "MICROVM_STATE_DIR").then(|| "/state".to_string());
610        assert_eq!(default_state_root(&pinned), PathBuf::from("/state"));
611    }
612}