Skip to main content

microvms_domain/
names.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Named MicroVMs: the record that lets 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 an adopt needs (id, endpoint, agent token, region), so a name replaces the
8//! whole triple rather than just the id.
9//!
10//! This is the half with no I/O: the name rule, the record and its JSON form, and
11//! [`resolve_record`] over a store's answer. The stores themselves (`NameStore` and the
12//! CLI's `FileNameStore`) are in `microvms_core::names`.
13//!
14//! **A record holds a secret.** The agent token is a bearer credential for the VM, and
15//! the optional identity seed can impersonate the launching host to it. Keep records in
16//! private storage; neither value appears in `Debug` output or error messages.
17
18use std::path::PathBuf;
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Error, ErrorKind};
23use crate::identity::TunnelIdentity;
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, registered `at` seconds after the
98    /// Unix epoch.
99    ///
100    /// Refuses an illegal name or an empty id, endpoint, or token: a record missing any of
101    /// them resolves to a VM nobody can adopt. The time is the caller's, since this crate
102    /// doesn't read the clock (ARCH-6); `microvms_core::prelude::NameRecordExt::new` stamps
103    /// the current time.
104    pub fn new_at(
105        name: impl Into<String>,
106        microvm_id: impl Into<String>,
107        endpoint: impl Into<String>,
108        agent_token: impl Into<String>,
109        region: impl Into<String>,
110        at: u64,
111    ) -> Result<Self, Error> {
112        let record = Self {
113            name: name.into(),
114            microvm_id: microvm_id.into(),
115            endpoint: endpoint.into(),
116            agent_token: agent_token.into(),
117            region: region.into(),
118            at,
119            identity_host_seed: None,
120            identity_vm_public_key: None,
121            egress_posture: None,
122        };
123        record.check()?;
124        Ok(record)
125    }
126
127    /// The record as JSON, secrets included — the form to store privately.
128    pub fn to_json(&self) -> serde_json::Value {
129        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
130    }
131
132    /// The record as JSON without its two secrets, the agent token and the identity host
133    /// seed: the form a listing prints, redacted as `Debug` redacts.
134    ///
135    /// Built from the fields it keeps rather than by removing the secrets from
136    /// [`NameRecord::to_json`], so a field added to the record stays out of every listing until
137    /// someone decides it can be shown.
138    pub fn redacted_json(&self) -> serde_json::Value {
139        let mut value = serde_json::json!({
140            "name": self.name,
141            "microvmId": self.microvm_id,
142            "endpoint": self.endpoint,
143            "region": self.region,
144            "at": self.at,
145        });
146        if let Some(object) = value.as_object_mut() {
147            if let Some(key) = &self.identity_vm_public_key {
148                object.insert("identityVmPublicKey".into(), key.clone().into());
149            }
150            if let Some(posture) = &self.egress_posture {
151                object.insert("egressPosture".into(), posture.clone().into());
152            }
153        }
154        value
155    }
156
157    /// A record from its JSON form, checked the way [`NameRecord::new_at`] checks.
158    pub fn from_json(value: serde_json::Value) -> Result<Self, Error> {
159        // The serde message can quote the offending value, which may be the token itself.
160        let record: Self = serde_json::from_value(value).map_err(|_| {
161            Error::invalid_arg(
162                "not a name record: it needs string name, microvmId, endpoint, agentToken, \
163                 and region, and a numeric at",
164            )
165        })?;
166        record.check()?;
167        Ok(record)
168    }
169
170    /// The record's tunnel identity, when it carries one.
171    ///
172    /// Refuses a record holding one half of the pair, or a half that doesn't decode, rather
173    /// than reading it as unverifiable: the record claims a verifiable VM, and dropping the
174    /// claim would hide that it's broken.
175    pub fn tunnel_identity(&self) -> Result<Option<TunnelIdentity>, Error> {
176        match (&self.identity_host_seed, &self.identity_vm_public_key) {
177            (None, None) => Ok(None),
178            (Some(seed), Some(pin)) => TunnelIdentity::from_encoded_parts(seed, pin).map(Some),
179            _ => Err(Error::invalid_arg(format!(
180                "the record for {:?} carries one half of its tunnel identity and not the other; \
181                 the host seed and the VM public key are a pair",
182                self.name
183            ))),
184        }
185    }
186
187    /// Records `identity`'s pair, or clears it.
188    pub fn set_tunnel_identity(&mut self, identity: Option<&TunnelIdentity>) {
189        self.identity_host_seed = identity.map(TunnelIdentity::host_seed_base64);
190        self.identity_vm_public_key = identity.map(TunnelIdentity::vm_public_key_base64);
191    }
192
193    /// The region the VM runs in, as this client wrote it into the record.
194    pub fn region(&self) -> Region {
195        Region::as_written(&self.region)
196    }
197
198    fn check(&self) -> Result<(), Error> {
199        validate_name(&self.name).map_err(Error::invalid_arg)?;
200        for (field, value) in [
201            ("microvmId", &self.microvm_id),
202            ("endpoint", &self.endpoint),
203            ("agentToken", &self.agent_token),
204            ("region", &self.region),
205        ] {
206            if value.is_empty() {
207                return Err(Error::invalid_arg(format!(
208                    "name record {:?} has an empty {field}; a record must carry everything an \
209                     adopt needs",
210                    self.name
211                )));
212            }
213        }
214        Ok(())
215    }
216}
217
218/// Redacts the agent token and the identity seed: this type reaches logs through errors
219/// and test output, and both values are credentials.
220impl std::fmt::Debug for NameRecord {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("NameRecord")
223            .field("name", &self.name)
224            .field("microvm_id", &self.microvm_id)
225            .field("endpoint", &self.endpoint)
226            .field("agent_token", &"<redacted>")
227            .field("region", &self.region)
228            .field("at", &self.at)
229            .field(
230                "identity_host_seed",
231                &self.identity_host_seed.as_ref().map(|_| "<redacted>"),
232            )
233            .field("identity_vm_public_key", &self.identity_vm_public_key)
234            .field("egress_posture", &self.egress_posture)
235            .finish()
236    }
237}
238
239/// The record for `name` out of what a store answered, refused when it is missing or
240/// registered in another region.
241///
242/// `found` is the store's answer for `name`, and `store` describes the store for the error
243/// message. The region check is the guard: an id from one region addresses nothing in another,
244/// and adopting it would fail at the first control-plane call with a not-found that says
245/// nothing about the name.
246pub fn resolve_record(
247    name: &str,
248    found: Option<NameRecord>,
249    store: &str,
250    expected_region: Option<&Region>,
251) -> Result<NameRecord, Error> {
252    let record = found.ok_or_else(|| {
253        Error::new(
254            ErrorKind::Precondition,
255            format!("no VM is named {name:?} in {store}; register one first, or adopt by id"),
256        )
257    })?;
258    if let Some(expected) = expected_region
259        && expected.as_str() != record.region
260    {
261        return Err(Error::invalid_arg(format!(
262            "{name:?} was registered in {}, not {}",
263            record.region,
264            expected.as_str()
265        )));
266    }
267    Ok(record)
268}
269
270/// The legacy pure state-directory calculation retained for source compatibility.
271///
272/// This helper does not resolve the operating system's home directory and can return a
273/// relative path. Production storage uses the fallible `microvms_edges::names::state_root`
274/// resolver, re-exported by `microvms_core::names`, instead.
275pub fn default_state_root(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
276    if let Some(dir) = env("MICROVM_STATE_DIR") {
277        return PathBuf::from(dir);
278    }
279    let home = env("HOME").unwrap_or_else(|| ".".to_string());
280    PathBuf::from(home).join(".microvm").join("runs")
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn record(name: &str, id: &str) -> NameRecord {
288        NameRecord::new_at(
289            name,
290            id,
291            "https://vm.example",
292            "secret-token",
293            "us-east-1",
294            1,
295        )
296        .expect("ok")
297    }
298
299    /// A record's pair reads back as the identity it was set from; no pair reads as none; half a
300    /// pair, or a half that doesn't decode, is refused by name.
301    #[test]
302    fn a_records_tunnel_identity_is_the_pair_or_nothing() {
303        let identity = crate::identity::LaunchIdentity::from_seeds([7; 32], [9; 32])
304            .expect("valid seeds")
305            .keep();
306        let mut kept = record("ci", "microvm-a");
307        assert!(kept.tunnel_identity().expect("no pair").is_none());
308        kept.set_tunnel_identity(Some(&identity));
309        let read = kept.tunnel_identity().expect("a pair").expect("kept");
310        assert_eq!(read.host_seed(), identity.host_seed());
311        assert_eq!(read.vm_public_key(), identity.vm_public_key());
312
313        let mut torn = kept.clone();
314        torn.identity_host_seed = None;
315        let refused = torn.tunnel_identity().expect_err("half a pair");
316        assert!(refused.to_string().contains("one half"), "{refused}");
317        let mut garbled = kept.clone();
318        garbled.identity_vm_public_key = Some("not base64!".into());
319        assert!(garbled.tunnel_identity().is_err());
320
321        kept.set_tunnel_identity(None);
322        assert_eq!(
323            (kept.identity_host_seed, kept.identity_vm_public_key),
324            (None, None)
325        );
326    }
327
328    #[test]
329    fn neither_debug_nor_a_rejection_prints_a_secret() {
330        let mut canaried = record("ci", "microvm-a");
331        canaried.agent_token = "tok-CANARY".into();
332        canaried.identity_host_seed = Some("seed-CANARY".into());
333        let debug = format!("{canaried:?}");
334        assert!(!debug.contains("CANARY"), "{debug}");
335        assert!(debug.contains("<redacted>"), "{debug}");
336        let error = NameRecord::from_json(serde_json::json!({
337            "name": "ci", "microvmId": "microvm-a", "endpoint": "e",
338            "agentToken": "tok-CANARY", "region": "us-east-1", "at": "not-a-number"
339        }))
340        .expect_err("at must be numeric");
341        assert!(!error.to_string().contains("CANARY"), "{error}");
342    }
343
344    /// **The redacted form is the stored one minus exactly the two secrets (#267).** Every
345    /// field set, so a field added to the record and not decided on here shows up as a
346    /// difference rather than passing unseen.
347    ///
348    /// **Falsification**: build `redacted_json` as `to_json` does and the token and the seed
349    /// are in it.
350    #[test]
351    fn the_redacted_form_is_the_record_without_its_secrets() {
352        let mut full = record("ci", "microvm-a");
353        full.agent_token = "tok-CANARY".into();
354        full.identity_host_seed = Some("seed-CANARY".into());
355        full.identity_vm_public_key = Some("pin".into());
356        full.egress_posture = Some("open".into());
357        let redacted = full.redacted_json();
358        assert!(!redacted.to_string().contains("CANARY"), "{redacted}");
359
360        let mut expected = full.to_json();
361        let object = expected.as_object_mut().expect("an object");
362        object.remove("agentToken");
363        object.remove("identityHostSeed");
364        assert_eq!(redacted, expected);
365    }
366
367    #[test]
368    fn records_refuse_illegal_names_and_missing_fields() {
369        let new =
370            |name: &str, token: &str| NameRecord::new_at(name, "id", "e", token, "us-east-1", 1);
371        assert!(new("microvm-x", "t").is_err());
372        assert!(new("has space", "t").is_err());
373        let error = new("ci", "").expect_err("no token");
374        assert!(error.to_string().contains("agentToken"), "{error}");
375        let round = NameRecord::from_json(record("ci", "microvm-a").to_json()).expect("ok");
376        assert_eq!(round, record("ci", "microvm-a"));
377    }
378
379    #[test]
380    fn a_record_carries_the_time_it_was_given() {
381        let record =
382            NameRecord::new_at("ci", "id", "e", "t", "us-east-1", 1_789_000_000).expect("ok");
383        assert_eq!(record.at, 1_789_000_000);
384    }
385
386    #[test]
387    fn resolving_refuses_a_missing_name_and_a_foreign_region() {
388        let missing = resolve_record("ci", None, "/state/names", None).expect_err("no such name");
389        assert_eq!(missing.kind(), ErrorKind::Precondition);
390        assert!(missing.to_string().contains("/state/names"), "{missing}");
391        let found = || Some(record("ci", "microvm-a"));
392        assert_eq!(
393            resolve_record("ci", found(), "s", Some(&Region::US_EAST_1))
394                .expect("same region")
395                .microvm_id,
396            "microvm-a"
397        );
398        let foreign =
399            resolve_record("ci", found(), "s", Some(&Region::US_WEST_2)).expect_err("other region");
400        assert_eq!(foreign.kind(), ErrorKind::InvalidArg);
401        assert!(!foreign.to_string().contains("secret-token"), "{foreign}");
402    }
403
404    #[test]
405    fn the_state_root_is_the_variable_or_the_home_default() {
406        let pinned = |key: &str| (key == "MICROVM_STATE_DIR").then(|| "/state".to_string());
407        assert_eq!(default_state_root(&pinned), PathBuf::from("/state"));
408        let home = |key: &str| (key == "HOME").then(|| "/home/u".to_string());
409        assert_eq!(
410            default_state_root(&home),
411            PathBuf::from("/home/u/.microvm/runs")
412        );
413    }
414}