1use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{Error, ErrorKind};
24use crate::region::Region;
25
26pub 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 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#[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 pub agent_token: String,
77 pub region: String,
78 pub at: u64,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub identity_host_seed: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub identity_vm_public_key: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub egress_posture: Option<String>,
94}
95
96impl NameRecord {
97 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 pub fn to_json(&self) -> serde_json::Value {
128 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
129 }
130
131 pub fn from_json(value: serde_json::Value) -> Result<Self, Error> {
133 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 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
170impl 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
191pub trait NameStore: Send + Sync {
193 fn get(&self, name: &str) -> Result<Option<NameRecord>, Error>;
195 fn put(&self, record: &NameRecord) -> Result<(), Error>;
197 fn delete(&self, name: &str) -> Result<bool, Error>;
199 fn list(&self) -> Result<Vec<NameRecord>, Error>;
201
202 fn describe(&self) -> String {
204 "the name store".to_string()
205 }
206
207 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
224pub 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
255pub 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#[derive(Clone, Debug)]
266pub struct FileNameStore {
267 dir: PathBuf,
268}
269
270impl FileNameStore {
271 pub fn new(dir: impl Into<PathBuf>) -> Self {
273 Self { dir: dir.into() }
274 }
275
276 pub fn in_state_root(state_root: &Path) -> Self {
281 Self::new(state_root.join("names"))
282 }
283
284 pub fn default_location(env: &dyn Fn(&str) -> Option<String>) -> Self {
286 Self::in_state_root(&default_state_root(env))
287 }
288
289 pub fn dir(&self) -> &Path {
291 &self.dir
292 }
293
294 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 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 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
375fn 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 #[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 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 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}