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. A record's region is one this client wrote, so a name it
194    /// no longer lists is taken as written rather than refused.
195    pub fn region(&self) -> Region {
196        Region::unlisted(self.region.as_str())
197    }
198
199    fn check(&self) -> Result<(), Error> {
200        validate_name(&self.name).map_err(Error::invalid_arg)?;
201        for (field, value) in [
202            ("microvmId", &self.microvm_id),
203            ("endpoint", &self.endpoint),
204            ("agentToken", &self.agent_token),
205            ("region", &self.region),
206        ] {
207            if value.is_empty() {
208                return Err(Error::invalid_arg(format!(
209                    "name record {:?} has an empty {field}; a record must carry everything an \
210                     adopt needs",
211                    self.name
212                )));
213            }
214        }
215        Ok(())
216    }
217}
218
219/// Redacts the agent token and the identity seed: this type reaches logs through errors
220/// and test output, and both values are credentials.
221impl std::fmt::Debug for NameRecord {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        f.debug_struct("NameRecord")
224            .field("name", &self.name)
225            .field("microvm_id", &self.microvm_id)
226            .field("endpoint", &self.endpoint)
227            .field("agent_token", &"<redacted>")
228            .field("region", &self.region)
229            .field("at", &self.at)
230            .field(
231                "identity_host_seed",
232                &self.identity_host_seed.as_ref().map(|_| "<redacted>"),
233            )
234            .field("identity_vm_public_key", &self.identity_vm_public_key)
235            .field("egress_posture", &self.egress_posture)
236            .finish()
237    }
238}
239
240/// The record for `name` out of what a store answered, refused when it is missing or
241/// registered in another region.
242///
243/// `found` is the store's answer for `name`, and `store` describes the store for the error
244/// message. The region check is the guard: an id from one region addresses nothing in another,
245/// and adopting it would fail at the first control-plane call with a not-found that says
246/// nothing about the name.
247pub fn resolve_record(
248    name: &str,
249    found: Option<NameRecord>,
250    store: &str,
251    expected_region: Option<&Region>,
252) -> Result<NameRecord, Error> {
253    let record = found.ok_or_else(|| {
254        Error::new(
255            ErrorKind::Precondition,
256            format!("no VM is named {name:?} in {store}; register one first, or adopt by id"),
257        )
258    })?;
259    if let Some(expected) = expected_region
260        && expected.as_str() != record.region
261    {
262        return Err(Error::invalid_arg(format!(
263            "{name:?} was registered in {}, not {}",
264            record.region,
265            expected.as_str()
266        )));
267    }
268    Ok(record)
269}
270
271/// The legacy pure state-directory calculation retained for source compatibility.
272///
273/// This helper does not resolve the operating system's home directory and can return a
274/// relative path. Production storage uses the fallible `microvms_edges::names::state_root`
275/// resolver, re-exported by `microvms_core::names`, instead.
276pub fn default_state_root(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
277    if let Some(dir) = env("MICROVM_STATE_DIR") {
278        return PathBuf::from(dir);
279    }
280    let home = env("HOME").unwrap_or_else(|| ".".to_string());
281    PathBuf::from(home).join(".microvm").join("runs")
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn record(name: &str, id: &str) -> NameRecord {
289        NameRecord::new_at(
290            name,
291            id,
292            "https://vm.example",
293            "secret-token",
294            "us-east-1",
295            1,
296        )
297        .expect("ok")
298    }
299
300    /// A record's pair reads back as the identity it was set from; no pair reads as none; half a
301    /// pair, or a half that doesn't decode, is refused by name.
302    #[test]
303    fn a_records_tunnel_identity_is_the_pair_or_nothing() {
304        let identity = crate::identity::LaunchIdentity::from_seeds([7; 32], [9; 32])
305            .expect("valid seeds")
306            .keep();
307        let mut kept = record("ci", "microvm-a");
308        assert!(kept.tunnel_identity().expect("no pair").is_none());
309        kept.set_tunnel_identity(Some(&identity));
310        let read = kept.tunnel_identity().expect("a pair").expect("kept");
311        assert_eq!(read.host_seed(), identity.host_seed());
312        assert_eq!(read.vm_public_key(), identity.vm_public_key());
313
314        let mut torn = kept.clone();
315        torn.identity_host_seed = None;
316        let refused = torn.tunnel_identity().expect_err("half a pair");
317        assert!(refused.to_string().contains("one half"), "{refused}");
318        let mut garbled = kept.clone();
319        garbled.identity_vm_public_key = Some("not base64!".into());
320        assert!(garbled.tunnel_identity().is_err());
321
322        kept.set_tunnel_identity(None);
323        assert_eq!(
324            (kept.identity_host_seed, kept.identity_vm_public_key),
325            (None, None)
326        );
327    }
328
329    #[test]
330    fn neither_debug_nor_a_rejection_prints_a_secret() {
331        let mut canaried = record("ci", "microvm-a");
332        canaried.agent_token = "tok-CANARY".into();
333        canaried.identity_host_seed = Some("seed-CANARY".into());
334        let debug = format!("{canaried:?}");
335        assert!(!debug.contains("CANARY"), "{debug}");
336        assert!(debug.contains("<redacted>"), "{debug}");
337        let error = NameRecord::from_json(serde_json::json!({
338            "name": "ci", "microvmId": "microvm-a", "endpoint": "e",
339            "agentToken": "tok-CANARY", "region": "us-east-1", "at": "not-a-number"
340        }))
341        .expect_err("at must be numeric");
342        assert!(!error.to_string().contains("CANARY"), "{error}");
343    }
344
345    /// **The redacted form is the stored one minus exactly the two secrets (#267).** Every
346    /// field set, so a field added to the record and not decided on here shows up as a
347    /// difference rather than passing unseen.
348    ///
349    /// **Falsification**: build `redacted_json` as `to_json` does and the token and the seed
350    /// are in it.
351    #[test]
352    fn the_redacted_form_is_the_record_without_its_secrets() {
353        let mut full = record("ci", "microvm-a");
354        full.agent_token = "tok-CANARY".into();
355        full.identity_host_seed = Some("seed-CANARY".into());
356        full.identity_vm_public_key = Some("pin".into());
357        full.egress_posture = Some("open".into());
358        let redacted = full.redacted_json();
359        assert!(!redacted.to_string().contains("CANARY"), "{redacted}");
360
361        let mut expected = full.to_json();
362        let object = expected.as_object_mut().expect("an object");
363        object.remove("agentToken");
364        object.remove("identityHostSeed");
365        assert_eq!(redacted, expected);
366    }
367
368    #[test]
369    fn records_refuse_illegal_names_and_missing_fields() {
370        let new =
371            |name: &str, token: &str| NameRecord::new_at(name, "id", "e", token, "us-east-1", 1);
372        assert!(new("microvm-x", "t").is_err());
373        assert!(new("has space", "t").is_err());
374        let error = new("ci", "").expect_err("no token");
375        assert!(error.to_string().contains("agentToken"), "{error}");
376        let round = NameRecord::from_json(record("ci", "microvm-a").to_json()).expect("ok");
377        assert_eq!(round, record("ci", "microvm-a"));
378    }
379
380    #[test]
381    fn a_record_carries_the_time_it_was_given() {
382        let record =
383            NameRecord::new_at("ci", "id", "e", "t", "us-east-1", 1_789_000_000).expect("ok");
384        assert_eq!(record.at, 1_789_000_000);
385    }
386
387    #[test]
388    fn resolving_refuses_a_missing_name_and_a_foreign_region() {
389        let missing = resolve_record("ci", None, "/state/names", None).expect_err("no such name");
390        assert_eq!(missing.kind(), ErrorKind::Precondition);
391        assert!(missing.to_string().contains("/state/names"), "{missing}");
392        let found = || Some(record("ci", "microvm-a"));
393        assert_eq!(
394            resolve_record("ci", found(), "s", Some(&Region::UsEast1))
395                .expect("same region")
396                .microvm_id,
397            "microvm-a"
398        );
399        let foreign =
400            resolve_record("ci", found(), "s", Some(&Region::UsWest2)).expect_err("other region");
401        assert_eq!(foreign.kind(), ErrorKind::InvalidArg);
402        assert!(!foreign.to_string().contains("secret-token"), "{foreign}");
403    }
404
405    #[test]
406    fn the_state_root_is_the_variable_or_the_home_default() {
407        let pinned = |key: &str| (key == "MICROVM_STATE_DIR").then(|| "/state".to_string());
408        assert_eq!(default_state_root(&pinned), PathBuf::from("/state"));
409        let home = |key: &str| (key == "HOME").then(|| "/home/u".to_string());
410        assert_eq!(
411            default_state_root(&home),
412            PathBuf::from("/home/u/.microvm/runs")
413        );
414    }
415}