microvms-domain 1.3.0

The MicroVMs client's rules and values: sizing, cost, regions, names, errors, with no I/O
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
// SPDX-License-Identifier: Apache-2.0
//! Named MicroVMs: the record that lets a later process find a VM by name and adopt it.
//!
//! The platform offers no lookup of its own. `RunMicrovm` takes no tags and tagging a
//! running MicroVM fails (docs/PLATFORM.md, "Tagging works on images and not on
//! MicroVMs"), so a name has to live beside the caller. A [`NameRecord`] carries
//! everything an adopt needs (id, endpoint, agent token, region), so a name replaces the
//! whole triple rather than just the id.
//!
//! This is the half with no I/O: the name rule, the record and its JSON form, and
//! [`resolve_record`] over a store's answer. The stores themselves (`NameStore` and the
//! CLI's `FileNameStore`) are in `microvms_core::names`.
//!
//! **A record holds a secret.** The agent token is a bearer credential for the VM, and
//! the optional identity seed can impersonate the launching host to it. Keep records in
//! private storage; neither value appears in `Debug` output or error messages.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::error::{Error, ErrorKind};
use crate::identity::TunnelIdentity;
use crate::region::Region;

/// A VM name's shape: ASCII letters, digits, `-`, `_`, at most 128 bytes, and never a
/// MicroVM id prefix.
///
/// The charset is the image-name pattern (`[a-zA-Z0-9-_]+`). It makes the CLI's
/// identifier resolution total — an identifier starting with `microvm-` can only be an id,
/// because a legal name is refused that prefix, and an ARN cannot match because `:` is
/// outside the set — and it makes every name a safe file name.
pub fn validate_name(name: &str) -> Result<(), String> {
    if name.is_empty() {
        return Err("a VM name cannot be empty".to_string());
    }
    if name.len() > 128 {
        return Err(format!(
            "a VM name is at most 128 bytes; this one is {}",
            name.len()
        ));
    }
    // Both spellings: `microvm-` is the id prefix the real service answers (measured
    // 2026-08-28, first live run of names — the fakes' `mvm-` fixture shape let a
    // passthrough keyed on `mvm-` alone pass every scripted test and fail against AWS),
    // and `mvm-` stays refused because it is the fixture shape every scripted body uses.
    if name.starts_with("microvm-") || name.starts_with("mvm-") {
        return Err(format!(
            "{name:?} starts with a MicroVM id prefix — a name shaped like an id would make \
             `microvm suspend <identifier>` ambiguous about which VM it addresses"
        ));
    }
    if let Some(bad) = name
        .chars()
        .find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
    {
        return Err(format!(
            "{bad:?} is not a legal VM-name character: names take ASCII letters, digits, `-` \
             and `_`, the image-name pattern"
        ));
    }
    Ok(())
}

/// One kept VM's name, and everything an adopt needs to address it.
///
/// `camelCase` on the wire: this is the CLI registry's on-disk format, read by later
/// versions, so fields are only ever added, and optional ones default when absent.
#[derive(Clone, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NameRecord {
    pub name: String,
    pub microvm_id: String,
    pub endpoint: String,
    /// The launch's agent token: a bearer credential for the VM.
    pub agent_token: String,
    pub region: String,
    /// Seconds since the epoch when the name was registered.
    pub at: u64,
    /// The launching host's identity secret, base64, when `run --identity` generated one.
    ///
    /// Persisted for the reason the agent token is: a later `tunnel --verify-identity`
    /// needs it. It raises what a stolen record can do from "call the VM" to "call the VM
    /// and impersonate the launching host to it", which is the same trust domain.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity_host_seed: Option<String>,
    /// The VM's public key, base64 — the pin `--verify-identity` checks the far end against.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity_vm_public_key: Option<String>,
    /// The egress posture label of the launch this name was registered for, when the
    /// registering command launched the VM. `None` means unknown, never "open".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub egress_posture: Option<String>,
}

impl NameRecord {
    /// A record for a VM this process can already address, registered `at` seconds after the
    /// Unix epoch.
    ///
    /// Refuses an illegal name or an empty id, endpoint, or token: a record missing any of
    /// them resolves to a VM nobody can adopt. The time is the caller's, since this crate
    /// doesn't read the clock (ARCH-6); `microvms_core::prelude::NameRecordExt::new` stamps
    /// the current time.
    pub fn new_at(
        name: impl Into<String>,
        microvm_id: impl Into<String>,
        endpoint: impl Into<String>,
        agent_token: impl Into<String>,
        region: impl Into<String>,
        at: u64,
    ) -> Result<Self, Error> {
        let record = Self {
            name: name.into(),
            microvm_id: microvm_id.into(),
            endpoint: endpoint.into(),
            agent_token: agent_token.into(),
            region: region.into(),
            at,
            identity_host_seed: None,
            identity_vm_public_key: None,
            egress_posture: None,
        };
        record.check()?;
        Ok(record)
    }

    /// The record as JSON, secrets included — the form to store privately.
    pub fn to_json(&self) -> serde_json::Value {
        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
    }

    /// The record as JSON without its two secrets, the agent token and the identity host
    /// seed: the form a listing prints, redacted as `Debug` redacts.
    ///
    /// Built from the fields it keeps rather than by removing the secrets from
    /// [`NameRecord::to_json`], so a field added to the record stays out of every listing until
    /// someone decides it can be shown.
    pub fn redacted_json(&self) -> serde_json::Value {
        let mut value = serde_json::json!({
            "name": self.name,
            "microvmId": self.microvm_id,
            "endpoint": self.endpoint,
            "region": self.region,
            "at": self.at,
        });
        if let Some(object) = value.as_object_mut() {
            if let Some(key) = &self.identity_vm_public_key {
                object.insert("identityVmPublicKey".into(), key.clone().into());
            }
            if let Some(posture) = &self.egress_posture {
                object.insert("egressPosture".into(), posture.clone().into());
            }
        }
        value
    }

    /// A record from its JSON form, checked the way [`NameRecord::new_at`] checks.
    pub fn from_json(value: serde_json::Value) -> Result<Self, Error> {
        // The serde message can quote the offending value, which may be the token itself.
        let record: Self = serde_json::from_value(value).map_err(|_| {
            Error::invalid_arg(
                "not a name record: it needs string name, microvmId, endpoint, agentToken, \
                 and region, and a numeric at",
            )
        })?;
        record.check()?;
        Ok(record)
    }

    /// The record's tunnel identity, when it carries one.
    ///
    /// Refuses a record holding one half of the pair, or a half that doesn't decode, rather
    /// than reading it as unverifiable: the record claims a verifiable VM, and dropping the
    /// claim would hide that it's broken.
    pub fn tunnel_identity(&self) -> Result<Option<TunnelIdentity>, Error> {
        match (&self.identity_host_seed, &self.identity_vm_public_key) {
            (None, None) => Ok(None),
            (Some(seed), Some(pin)) => TunnelIdentity::from_encoded_parts(seed, pin).map(Some),
            _ => Err(Error::invalid_arg(format!(
                "the record for {:?} carries one half of its tunnel identity and not the other; \
                 the host seed and the VM public key are a pair",
                self.name
            ))),
        }
    }

    /// Records `identity`'s pair, or clears it.
    pub fn set_tunnel_identity(&mut self, identity: Option<&TunnelIdentity>) {
        self.identity_host_seed = identity.map(TunnelIdentity::host_seed_base64);
        self.identity_vm_public_key = identity.map(TunnelIdentity::vm_public_key_base64);
    }

    /// The region the VM runs in, as this client wrote it into the record.
    pub fn region(&self) -> Region {
        Region::as_written(&self.region)
    }

    fn check(&self) -> Result<(), Error> {
        validate_name(&self.name).map_err(Error::invalid_arg)?;
        for (field, value) in [
            ("microvmId", &self.microvm_id),
            ("endpoint", &self.endpoint),
            ("agentToken", &self.agent_token),
            ("region", &self.region),
        ] {
            if value.is_empty() {
                return Err(Error::invalid_arg(format!(
                    "name record {:?} has an empty {field}; a record must carry everything an \
                     adopt needs",
                    self.name
                )));
            }
        }
        Ok(())
    }
}

/// Redacts the agent token and the identity seed: this type reaches logs through errors
/// and test output, and both values are credentials.
impl std::fmt::Debug for NameRecord {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("NameRecord")
            .field("name", &self.name)
            .field("microvm_id", &self.microvm_id)
            .field("endpoint", &self.endpoint)
            .field("agent_token", &"<redacted>")
            .field("region", &self.region)
            .field("at", &self.at)
            .field(
                "identity_host_seed",
                &self.identity_host_seed.as_ref().map(|_| "<redacted>"),
            )
            .field("identity_vm_public_key", &self.identity_vm_public_key)
            .field("egress_posture", &self.egress_posture)
            .finish()
    }
}

/// The record for `name` out of what a store answered, refused when it is missing or
/// registered in another region.
///
/// `found` is the store's answer for `name`, and `store` describes the store for the error
/// message. The region check is the guard: an id from one region addresses nothing in another,
/// and adopting it would fail at the first control-plane call with a not-found that says
/// nothing about the name.
pub fn resolve_record(
    name: &str,
    found: Option<NameRecord>,
    store: &str,
    expected_region: Option<&Region>,
) -> Result<NameRecord, Error> {
    let record = found.ok_or_else(|| {
        Error::new(
            ErrorKind::Precondition,
            format!("no VM is named {name:?} in {store}; register one first, or adopt by id"),
        )
    })?;
    if let Some(expected) = expected_region
        && expected.as_str() != record.region
    {
        return Err(Error::invalid_arg(format!(
            "{name:?} was registered in {}, not {}",
            record.region,
            expected.as_str()
        )));
    }
    Ok(record)
}

/// The legacy pure state-directory calculation retained for source compatibility.
///
/// This helper does not resolve the operating system's home directory and can return a
/// relative path. Production storage uses the fallible `microvms_edges::names::state_root`
/// resolver, re-exported by `microvms_core::names`, instead.
pub fn default_state_root(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
    if let Some(dir) = env("MICROVM_STATE_DIR") {
        return PathBuf::from(dir);
    }
    let home = env("HOME").unwrap_or_else(|| ".".to_string());
    PathBuf::from(home).join(".microvm").join("runs")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn record(name: &str, id: &str) -> NameRecord {
        NameRecord::new_at(
            name,
            id,
            "https://vm.example",
            "secret-token",
            "us-east-1",
            1,
        )
        .expect("ok")
    }

    /// A record's pair reads back as the identity it was set from; no pair reads as none; half a
    /// pair, or a half that doesn't decode, is refused by name.
    #[test]
    fn a_records_tunnel_identity_is_the_pair_or_nothing() {
        let identity = crate::identity::LaunchIdentity::from_seeds([7; 32], [9; 32])
            .expect("valid seeds")
            .keep();
        let mut kept = record("ci", "microvm-a");
        assert!(kept.tunnel_identity().expect("no pair").is_none());
        kept.set_tunnel_identity(Some(&identity));
        let read = kept.tunnel_identity().expect("a pair").expect("kept");
        assert_eq!(read.host_seed(), identity.host_seed());
        assert_eq!(read.vm_public_key(), identity.vm_public_key());

        let mut torn = kept.clone();
        torn.identity_host_seed = None;
        let refused = torn.tunnel_identity().expect_err("half a pair");
        assert!(refused.to_string().contains("one half"), "{refused}");
        let mut garbled = kept.clone();
        garbled.identity_vm_public_key = Some("not base64!".into());
        assert!(garbled.tunnel_identity().is_err());

        kept.set_tunnel_identity(None);
        assert_eq!(
            (kept.identity_host_seed, kept.identity_vm_public_key),
            (None, None)
        );
    }

    #[test]
    fn neither_debug_nor_a_rejection_prints_a_secret() {
        let mut canaried = record("ci", "microvm-a");
        canaried.agent_token = "tok-CANARY".into();
        canaried.identity_host_seed = Some("seed-CANARY".into());
        let debug = format!("{canaried:?}");
        assert!(!debug.contains("CANARY"), "{debug}");
        assert!(debug.contains("<redacted>"), "{debug}");
        let error = NameRecord::from_json(serde_json::json!({
            "name": "ci", "microvmId": "microvm-a", "endpoint": "e",
            "agentToken": "tok-CANARY", "region": "us-east-1", "at": "not-a-number"
        }))
        .expect_err("at must be numeric");
        assert!(!error.to_string().contains("CANARY"), "{error}");
    }

    /// **The redacted form is the stored one minus exactly the two secrets (#267).** Every
    /// field set, so a field added to the record and not decided on here shows up as a
    /// difference rather than passing unseen.
    ///
    /// **Falsification**: build `redacted_json` as `to_json` does and the token and the seed
    /// are in it.
    #[test]
    fn the_redacted_form_is_the_record_without_its_secrets() {
        let mut full = record("ci", "microvm-a");
        full.agent_token = "tok-CANARY".into();
        full.identity_host_seed = Some("seed-CANARY".into());
        full.identity_vm_public_key = Some("pin".into());
        full.egress_posture = Some("open".into());
        let redacted = full.redacted_json();
        assert!(!redacted.to_string().contains("CANARY"), "{redacted}");

        let mut expected = full.to_json();
        let object = expected.as_object_mut().expect("an object");
        object.remove("agentToken");
        object.remove("identityHostSeed");
        assert_eq!(redacted, expected);
    }

    #[test]
    fn records_refuse_illegal_names_and_missing_fields() {
        let new =
            |name: &str, token: &str| NameRecord::new_at(name, "id", "e", token, "us-east-1", 1);
        assert!(new("microvm-x", "t").is_err());
        assert!(new("has space", "t").is_err());
        let error = new("ci", "").expect_err("no token");
        assert!(error.to_string().contains("agentToken"), "{error}");
        let round = NameRecord::from_json(record("ci", "microvm-a").to_json()).expect("ok");
        assert_eq!(round, record("ci", "microvm-a"));
    }

    #[test]
    fn a_record_carries_the_time_it_was_given() {
        let record =
            NameRecord::new_at("ci", "id", "e", "t", "us-east-1", 1_789_000_000).expect("ok");
        assert_eq!(record.at, 1_789_000_000);
    }

    #[test]
    fn resolving_refuses_a_missing_name_and_a_foreign_region() {
        let missing = resolve_record("ci", None, "/state/names", None).expect_err("no such name");
        assert_eq!(missing.kind(), ErrorKind::Precondition);
        assert!(missing.to_string().contains("/state/names"), "{missing}");
        let found = || Some(record("ci", "microvm-a"));
        assert_eq!(
            resolve_record("ci", found(), "s", Some(&Region::US_EAST_1))
                .expect("same region")
                .microvm_id,
            "microvm-a"
        );
        let foreign =
            resolve_record("ci", found(), "s", Some(&Region::US_WEST_2)).expect_err("other region");
        assert_eq!(foreign.kind(), ErrorKind::InvalidArg);
        assert!(!foreign.to_string().contains("secret-token"), "{foreign}");
    }

    #[test]
    fn the_state_root_is_the_variable_or_the_home_default() {
        let pinned = |key: &str| (key == "MICROVM_STATE_DIR").then(|| "/state".to_string());
        assert_eq!(default_state_root(&pinned), PathBuf::from("/state"));
        let home = |key: &str| (key == "HOME").then(|| "/home/u".to_string());
        assert_eq!(
            default_state_root(&home),
            PathBuf::from("/home/u/.microvm/runs")
        );
    }
}