use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Error, ErrorKind};
use crate::identity::TunnelIdentity;
use crate::region::Region;
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()
));
}
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(())
}
#[derive(Clone, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NameRecord {
pub name: String,
pub microvm_id: String,
pub endpoint: String,
pub agent_token: String,
pub region: String,
pub at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_host_seed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_vm_public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub egress_posture: Option<String>,
}
impl NameRecord {
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)
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
}
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
}
pub fn from_json(value: serde_json::Value) -> Result<Self, Error> {
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)
}
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
))),
}
}
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);
}
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(())
}
}
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()
}
}
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)
}
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")
}
#[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}");
}
#[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")
);
}
}