1use std::path::PathBuf;
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Error, ErrorKind};
23use crate::identity::TunnelIdentity;
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_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 pub fn to_json(&self) -> serde_json::Value {
129 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
130 }
131
132 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 pub fn from_json(value: serde_json::Value) -> Result<Self, Error> {
159 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 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 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 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
218impl 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
239pub 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
270pub 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 #[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 #[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}