Skip to main content

microvms_app/
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 record and its rules are `microvms_domain::names`, re-exported here. A [`NameRecord`]
5//! carries everything [`crate::sandbox::Sandbox::adopt`] needs (id, endpoint, agent token,
6//! region), so a name replaces the whole triple rather than just the id.
7//!
8//! Storage is a [`NameStore`]. `FileNameStore` (in `microvms-edges`) is the CLI's registry. A
9//! caller with its own database implements [`NameStore`] or keeps [`NameRecord`]'s JSON form
10//! wherever it likes.
11//!
12//! **A record holds a secret.** The agent token is a bearer credential for the VM, and the
13//! optional identity seed can impersonate the launching host to it. Keep records in private
14//! storage; neither value appears in `Debug` output or error messages.
15
16pub use microvms_domain::names::*;
17
18use crate::error::{Error, ErrorKind};
19use crate::region::Region;
20use crate::session::Session;
21
22/// Where names are kept. Implement it to keep records in your own database.
23pub trait NameStore: Send + Sync {
24    /// The record registered under `name`, or `None` when the name is free.
25    fn get(&self, name: &str) -> Result<Option<NameRecord>, Error>;
26    /// Writes `record` under its name, replacing any earlier record.
27    fn put(&self, record: &NameRecord) -> Result<(), Error>;
28    /// Removes `name`, answering whether a record was there.
29    fn delete(&self, name: &str) -> Result<bool, Error>;
30    /// Every readable record, sorted by name.
31    fn list(&self) -> Result<Vec<NameRecord>, Error>;
32
33    /// Where this store keeps names, for error messages.
34    fn describe(&self) -> String {
35        "the name store".to_string()
36    }
37
38    /// Removes every name registered to `microvm_id` and returns them, sorted.
39    ///
40    /// Every match rather than the first: one VM can carry two names in one registry
41    /// (measured live 2026-09-02: terminating through one alias left the other pointing
42    /// at a VM that no longer existed).
43    fn release_by_vm(&self, microvm_id: &str) -> Result<Vec<String>, Error> {
44        let mut released = Vec::new();
45        for record in self.list()? {
46            if record.microvm_id == microvm_id && self.delete(&record.name)? {
47                released.push(record.name);
48            }
49        }
50        released.sort();
51        Ok(released)
52    }
53}
54
55/// The record for `name`, refused when it is missing or registered in another region.
56///
57/// [`resolve_record`] over `store`'s answer.
58pub fn resolve(
59    store: &dyn NameStore,
60    name: &str,
61    expected_region: Option<&Region>,
62) -> Result<NameRecord, Error> {
63    resolve_record(name, store.get(name)?, &store.describe(), expected_region)
64}
65
66// ── import: registering a record this store did not write (#270) ─────────────
67
68/// The path an import's probe reads: zero bytes, present in every Linux guest.
69///
70/// **Not `/v1/health`.** That route is open (the platform forwards no external traffic until
71/// the run hook returns 200, so reaching it implies nothing about the bearer), and a probe that
72/// stopped there would register a record whose token the daemon refuses on every later adopt.
73/// `GET /v1/fs/file` sits behind the daemon's token check, and `/dev/null` is the one path a
74/// read can't fail on for a reason that's about the file.
75pub const IMPORT_PROBE_PATH: &str = "/dev/null";
76
77/// Who holds a name in a store, for a record that would take it.
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub enum NameHolder {
80    /// Nothing is registered under the name.
81    Free,
82    /// The same VM is: an import refreshes the record.
83    Same,
84    /// Another VM is, by its id, or `None` for a record that doesn't read (a torn file keeps
85    /// its name taken, since its first holder may still be billing).
86    Other(Option<String>),
87}
88
89/// Who holds `record`'s name in `store`.
90pub fn holder(store: &dyn NameStore, record: &NameRecord) -> NameHolder {
91    match store.get(&record.name) {
92        Ok(None) => NameHolder::Free,
93        Ok(Some(existing)) if existing.microvm_id == record.microvm_id => NameHolder::Same,
94        Ok(Some(existing)) => NameHolder::Other(Some(existing.microvm_id)),
95        Err(_) => NameHolder::Other(None),
96    }
97}
98
99/// Proof that a record's VM answered one authenticated request with the record's token.
100///
101/// Only [`probe`] makes one, and [`import_probed`] takes one, so a record can't be written
102/// without it: the rule is the types', not a caller's order of calls.
103pub struct Probed {
104    microvm_id: String,
105    endpoint: String,
106    agent_token: String,
107}
108
109/// Redacts the token, a bearer credential for the VM.
110impl std::fmt::Debug for Probed {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("Probed")
113            .field("microvm_id", &self.microvm_id)
114            .field("endpoint", &self.endpoint)
115            .finish_non_exhaustive()
116    }
117}
118
119/// What an import did.
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub struct Imported {
122    /// Whether the name already held this VM's record, which the import refreshed.
123    pub replaced: bool,
124}
125
126/// One authenticated read of [`IMPORT_PROBE_PATH`] over `session`, which must be attached with
127/// `record`'s own endpoint and token.
128///
129/// An import authenticates the token, not the machine the record came from, so this read is
130/// the only proof the triple is live. A dead endpoint, a refused token (401) or an
131/// unbootstrapped daemon (503) fails here with the daemon's own class. Either answer to the
132/// read proves the bearer: absence sits behind the same token check as presence.
133pub async fn probe(session: &Session, record: &NameRecord) -> Result<Probed, Error> {
134    if session.endpoint() != record.endpoint || session.agent_token() != record.agent_token {
135        return Err(Error::invalid_arg(format!(
136            "the session isn't attached with the record for {:?}: a probe proves the token it's              sent with, so attach with the record's own endpoint and token",
137            record.name
138        )));
139    }
140    session.file_exists(IMPORT_PROBE_PATH).await?;
141    Ok(Probed {
142        microvm_id: record.microvm_id.clone(),
143        endpoint: record.endpoint.clone(),
144        agent_token: record.agent_token.clone(),
145    })
146}
147
148/// Writes `record` into `store` on the strength of `probed`, which must be the probe of this
149/// record's triple.
150///
151/// Refuses a name `store` holds for another VM, or can't read, and writes nothing: a name is a
152/// promise about which VM answers to it. The same VM under the same name is refreshed, and
153/// [`Imported::replaced`] says so.
154pub fn import_probed(
155    store: &dyn NameStore,
156    record: &NameRecord,
157    probed: Probed,
158) -> Result<Imported, Error> {
159    if probed.microvm_id != record.microvm_id
160        || probed.endpoint != record.endpoint
161        || probed.agent_token != record.agent_token
162    {
163        return Err(Error::invalid_arg(format!(
164            "the probe was of {}, not the record for {:?}: an import writes only the triple              that answered",
165            probed.microvm_id, record.name
166        )));
167    }
168    let record = NameRecord::from_json(record.to_json())?;
169    let replaced = match holder(store, &record) {
170        NameHolder::Free => false,
171        NameHolder::Same => true,
172        NameHolder::Other(holder) => return Err(taken(store, &record, holder)),
173    };
174    store.put(&record)?;
175    Ok(Imported { replaced })
176}
177
178/// Registers `record` in `store` once `session`, attached with the record's own endpoint and
179/// token, has answered one authenticated request (#270).
180///
181/// A name `store` holds for another VM is refused before the probe, so a refusal costs no
182/// request; a probe that fails writes nothing. [`probe`] and [`import_probed`] are the two
183/// halves, for a caller that checks more between them (the CLI's `attach --verify-identity`
184/// runs its identity handshake there).
185pub async fn import(
186    store: &dyn NameStore,
187    record: &NameRecord,
188    session: &Session,
189) -> Result<Imported, Error> {
190    if let NameHolder::Other(holder) = holder(store, record) {
191        return Err(taken(store, record, holder));
192    }
193    let probed = probe(session, record).await?;
194    import_probed(store, record, probed)
195}
196
197fn taken(store: &dyn NameStore, record: &NameRecord, holder: Option<String>) -> Error {
198    Error::new(
199        ErrorKind::Precondition,
200        format!(
201            "{:?} is already registered in {} to {}, and this import names {}: a name is a              promise about which VM answers to it, and nothing overwrites one silently",
202            record.name,
203            store.describe(),
204            holder.as_deref().unwrap_or("an unreadable record"),
205            record.microvm_id,
206        ),
207    )
208}
209
210#[cfg(test)]
211mod tests {
212    use std::collections::BTreeMap;
213    use std::sync::{Arc, Mutex};
214
215    use super::*;
216    use crate::session::testing::{Recorder, Reply, session_with};
217
218    /// A store in memory; `torn` names read as unreadable, as a killed write leaves one.
219    #[derive(Default)]
220    struct Memory {
221        records: Mutex<BTreeMap<String, NameRecord>>,
222        torn: Vec<String>,
223    }
224
225    impl NameStore for Memory {
226        fn get(&self, name: &str) -> Result<Option<NameRecord>, Error> {
227            if self.torn.iter().any(|torn| torn == name) {
228                return Err(Error::new(ErrorKind::Precondition, "torn"));
229            }
230            Ok(self.records.lock().expect("unpoisoned").get(name).cloned())
231        }
232        fn put(&self, record: &NameRecord) -> Result<(), Error> {
233            self.records
234                .lock()
235                .expect("unpoisoned")
236                .insert(record.name.clone(), record.clone());
237            Ok(())
238        }
239        fn delete(&self, name: &str) -> Result<bool, Error> {
240            Ok(self
241                .records
242                .lock()
243                .expect("unpoisoned")
244                .remove(name)
245                .is_some())
246        }
247        fn list(&self) -> Result<Vec<NameRecord>, Error> {
248            Ok(self
249                .records
250                .lock()
251                .expect("unpoisoned")
252                .values()
253                .cloned()
254                .collect())
255        }
256    }
257
258    /// The record `session_with`'s session is attached with.
259    fn record(name: &str, microvm_id: &str) -> NameRecord {
260        NameRecord::new_at(
261            name,
262            microvm_id,
263            "https://vm.example",
264            "agent-token-abcdef",
265            "us-east-1",
266            1,
267        )
268        .expect("a record")
269    }
270
271    fn answered(status: u16) -> Arc<Recorder> {
272        Recorder::with([Reply::Body(status, Vec::new())])
273    }
274
275    /// An import with a token the daemon refuses writes nothing, and says the daemon's class;
276    /// one it accepts writes the record, and a second of the same VM refreshes it (#270).
277    ///
278    /// **Falsification**: `verify/guards/faults/names-import.toml` entry
279    /// `app-import-writes-before-the-probe` puts the record before the probe, and the refused
280    /// row finds it written.
281    #[tokio::test]
282    async fn an_import_writes_only_after_the_vm_answers_with_the_records_token() {
283        let store = Memory::default();
284        let (refusing, _, _) = session_with(answered(401));
285        let error = import(&store, &record("ci", "mvm-1"), &refusing)
286            .await
287            .expect_err("a refused token");
288        assert_eq!(error.kind(), ErrorKind::Credentials, "{error}");
289        assert!(store.get("ci").expect("reads").is_none(), "nothing written");
290
291        let (answering, _, _) = session_with(answered(200));
292        let imported = import(&store, &record("ci", "mvm-1"), &answering)
293            .await
294            .expect("an answered probe");
295        assert_eq!(imported, Imported { replaced: false });
296        assert_eq!(
297            store.get("ci").expect("reads").map(|r| r.microvm_id),
298            Some("mvm-1".into())
299        );
300
301        let (again, _, _) = session_with(answered(404));
302        let refreshed = import(&store, &record("ci", "mvm-1"), &again)
303            .await
304            .expect("absence answers behind the same token check");
305        assert_eq!(refreshed, Imported { replaced: true });
306    }
307
308    /// A name another VM holds, or an unreadable record holds, is refused before any request,
309    /// and the holder's record is untouched.
310    #[tokio::test]
311    async fn an_import_refuses_a_taken_name_before_any_request() {
312        let store = Memory {
313            torn: vec!["torn".into()],
314            ..Memory::default()
315        };
316        store.put(&record("ci", "mvm-holder")).expect("seeded");
317        for name in ["ci", "torn"] {
318            let recorder = Recorder::with([]);
319            let (session, _, _) = session_with(Arc::clone(&recorder));
320            let error = import(&store, &record(name, "mvm-new"), &session)
321                .await
322                .expect_err("a taken name");
323            assert_eq!(error.kind(), ErrorKind::Precondition, "{name}: {error}");
324            assert!(recorder.requests().is_empty(), "{name}: no request");
325        }
326        assert_eq!(
327            store.get("ci").expect("reads").map(|r| r.microvm_id),
328            Some("mvm-holder".into())
329        );
330    }
331
332    /// A probe proves the session it was sent over, so it refuses a session attached with
333    /// another triple, and a write refuses the probe of another record.
334    #[tokio::test]
335    async fn a_probe_is_of_one_record_and_its_write_takes_only_that_one() {
336        let (session, _, _) = session_with(answered(200));
337        let mut stranger = record("ci", "mvm-1");
338        stranger.agent_token = "another-token".into();
339        let error = probe(&session, &stranger).await.expect_err("another token");
340        assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
341
342        // The proof is of one triple: another id, endpoint or token each refuses the write.
343        let mut other_endpoint = record("ci", "mvm-1");
344        other_endpoint.endpoint = "https://other.example".into();
345        let mut other_token = record("ci", "mvm-1");
346        other_token.agent_token = "another-token".into();
347        for (row, other) in [
348            ("the probe of another VM", record("ci", "mvm-2")),
349            ("another endpoint", other_endpoint),
350            ("another token", other_token),
351        ] {
352            let (answering, _, _) = session_with(answered(200));
353            let probed = probe(&answering, &record("ci", "mvm-1"))
354                .await
355                .expect("probed");
356            let store = Memory::default();
357            let error = import_probed(&store, &other, probed).expect_err(row);
358            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{row}: {error}");
359            assert!(
360                store.get("ci").expect("reads").is_none(),
361                "{row}: nothing written"
362            );
363        }
364    }
365
366    /// A proof's `Debug` names the VM it was of and never the token, a bearer credential.
367    #[tokio::test]
368    async fn a_probes_debug_names_the_vm_and_redacts_the_token() {
369        let (session, _, _) = session_with(answered(200));
370        let probed = probe(&session, &record("ci", "mvm-1"))
371            .await
372            .expect("probed");
373        let shown = format!("{probed:?}");
374        assert!(shown.contains("mvm-1"), "{shown}");
375        assert!(!shown.contains("agent-token-abcdef"), "{shown}");
376    }
377}