use std::collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const HOOK_PREFIX: &str = "/aws/lambda-microvms/runtime/v1";
#[derive(Deserialize, JsonSchema, Serialize)]
pub struct RunHookEnvelope {
#[serde(rename = "runHookPayload")]
pub run_hook_payload: Option<String>,
}
impl std::fmt::Debug for RunHookEnvelope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RunHookEnvelope")
.field(
"run_hook_payload",
&self.run_hook_payload.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
#[derive(Default, Deserialize, JsonSchema, Serialize)]
pub struct RunHook {
pub agent_token: String,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_seed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_host_public_key: Option<String>,
}
impl std::fmt::Debug for RunHook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RunHook")
.field("agent_token", &"[REDACTED]")
.field("env", &"[REDACTED]")
.field(
"identity_seed",
&self.identity_seed.as_ref().map(|_| "[REDACTED]"),
)
.field(
"identity_host_public_key",
&self.identity_host_public_key.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum RunHookError {
NotAnObject,
TokenMissingOrNotAString,
TokenEmpty,
EnvNotAnObject,
EnvValueNotAString(String),
IdentityNotAString(&'static str),
}
impl std::fmt::Display for RunHookError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RunHookError::NotAnObject => f.write_str(
"runHookPayload is not a JSON object. The platform wraps the string given to \
RunMicrovm, so the payload is one parse deeper than the request body: \
{\"runHookPayload\": \"{\\\"agent_token\\\": \\\"...\\\"}\"}",
),
RunHookError::TokenMissingOrNotAString => {
f.write_str("runHookPayload has no agent_token string")
}
RunHookError::TokenEmpty => f.write_str("agent_token is empty"),
RunHookError::EnvNotAnObject => f.write_str(
"env must be a JSON object of string keys to string values, and it is not an \
object",
),
RunHookError::EnvValueNotAString(key) => write!(
f,
"env[{key:?}] is not a string. Every launch-environment value is a string, \
because an environment variable is a string — a number or a nested object \
would have to be stringified by someone, and guessing which spelling the \
caller meant is how a credential arrives mangled"
),
RunHookError::IdentityNotAString(key) => write!(
f,
"{key} must be a string: standard base64 of exactly 32 bytes. The value is \
not quoted here because it is key material"
),
}
}
}
impl RunHook {
pub fn parse(raw: &str) -> Result<Self, RunHookError> {
let value: serde_json::Value =
serde_json::from_str(raw).map_err(|_| RunHookError::NotAnObject)?;
let object = value.as_object().ok_or(RunHookError::NotAnObject)?;
let agent_token = object
.get("agent_token")
.and_then(serde_json::Value::as_str)
.ok_or(RunHookError::TokenMissingOrNotAString)?;
if agent_token.is_empty() {
return Err(RunHookError::TokenEmpty);
}
let mut env = HashMap::new();
match object.get("env") {
None | Some(serde_json::Value::Null) => {}
Some(raw_env) => {
let entries = raw_env.as_object().ok_or(RunHookError::EnvNotAnObject)?;
for (key, value) in entries {
let value = value
.as_str()
.ok_or_else(|| RunHookError::EnvValueNotAString(key.clone()))?;
env.insert(key.clone(), value.to_string());
}
}
}
let identity_value = |key: &'static str| -> Result<Option<String>, RunHookError> {
match object.get(key) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(text)) => Ok(Some(text.clone())),
Some(_) => Err(RunHookError::IdentityNotAString(key)),
}
};
Ok(Self {
agent_token: agent_token.to_string(),
env,
identity_seed: identity_value(crate::identity::SEED_KEY)?,
identity_host_public_key: identity_value(crate::identity::HOST_PUBLIC_KEY_KEY)?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_hook_debug_retains_field_names_and_omits_private_contents() {
let hook = RunHook {
agent_token: "example-agent-token-value".into(),
env: HashMap::from([(
"EXAMPLE_PRIVATE_KEY".into(),
"example-environment-value".into(),
)]),
identity_seed: Some("example-identity-seed-value".into()),
identity_host_public_key: Some("example-host-key-value".into()),
};
for rendered in [format!("{hook:?}"), format!("{hook:#?}")] {
assert!(rendered.starts_with("RunHook {"));
for field in [
"agent_token",
"env",
"identity_seed",
"identity_host_public_key",
] {
assert!(rendered.contains(field));
}
for value in [
hook.agent_token.as_str(),
"EXAMPLE_PRIVATE_KEY",
"example-environment-value",
hook.identity_seed.as_deref().unwrap(),
hook.identity_host_public_key.as_deref().unwrap(),
] {
assert!(!rendered.contains(value));
}
}
}
#[test]
fn run_hook_envelope_debug_omits_the_nested_payload() {
let envelope = RunHookEnvelope {
run_hook_payload: Some(r#"{"agent_token":"example-nested-token"}"#.into()),
};
for rendered in [format!("{envelope:?}"), format!("{envelope:#?}")] {
assert!(rendered.starts_with("RunHookEnvelope {"));
assert!(rendered.contains("run_hook_payload"));
assert!(!rendered.contains("example-nested-token"));
assert!(!rendered.contains("agent_token"));
}
}
#[test]
fn the_envelope_key_is_the_platforms_own_camel_case_spelling() {
let envelope: RunHookEnvelope =
serde_json::from_str(r#"{"runHookPayload":"{}"}"#).expect("deserializes");
assert_eq!(envelope.run_hook_payload.as_deref(), Some("{}"));
assert_eq!(
serde_json::to_string(&envelope).expect("serializes"),
r#"{"runHookPayload":"{}"}"#
);
}
#[test]
fn an_envelope_with_no_payload_parses_as_none() {
let envelope: RunHookEnvelope = serde_json::from_str("{}").expect("deserializes");
assert!(envelope.run_hook_payload.is_none());
}
#[test]
fn a_payload_with_only_a_token_parses_with_an_empty_environment() {
let hook = RunHook::parse(r#"{"agent_token":"tok"}"#).expect("parses");
assert_eq!(hook.agent_token, "tok");
assert!(hook.env.is_empty());
}
#[test]
fn a_payload_with_an_environment_carries_every_pair_including_an_empty_value() {
let hook =
RunHook::parse(r#"{"agent_token":"tok","env":{"A":"1","EMPTY":""}}"#).expect("parses");
assert_eq!(hook.env.get("A").map(String::as_str), Some("1"));
assert_eq!(hook.env.get("EMPTY").map(String::as_str), Some(""));
assert_eq!(hook.env.len(), 2);
}
#[test]
fn the_identity_material_parses_when_present_and_is_none_when_absent() {
let with = RunHook::parse(
r#"{"agent_token":"tok","identity_seed":"c2VlZA==","identity_host_public_key":"aG9zdA=="}"#,
)
.expect("parses");
assert_eq!(with.identity_seed.as_deref(), Some("c2VlZA=="));
assert_eq!(with.identity_host_public_key.as_deref(), Some("aG9zdA=="));
let without = RunHook::parse(r#"{"agent_token":"tok"}"#).expect("parses");
assert!(without.identity_seed.is_none());
assert!(without.identity_host_public_key.is_none());
let nulled =
RunHook::parse(r#"{"agent_token":"tok","identity_seed":null}"#).expect("parses");
assert!(nulled.identity_seed.is_none());
}
#[test]
fn the_identity_field_names_are_the_declared_wire_keys() {
let hook = RunHook {
agent_token: "tok".to_string(),
env: HashMap::new(),
identity_seed: Some("seed".to_string()),
identity_host_public_key: Some("host".to_string()),
};
let value = serde_json::to_value(&hook).expect("serializes");
assert_eq!(
value
.get(crate::identity::SEED_KEY)
.and_then(|v| v.as_str()),
Some("seed"),
"the seed field must serialize under identity::SEED_KEY"
);
assert_eq!(
value
.get(crate::identity::HOST_PUBLIC_KEY_KEY)
.and_then(|v| v.as_str()),
Some("host"),
"the host-key field must serialize under identity::HOST_PUBLIC_KEY_KEY"
);
}
#[test]
fn an_identity_value_that_is_not_a_string_is_refused_by_key() {
let refusal = RunHook::parse(r#"{"agent_token":"tok","identity_seed":7}"#)
.expect_err("a number is not a base64 string");
assert_eq!(
refusal,
RunHookError::IdentityNotAString(crate::identity::SEED_KEY)
);
assert!(refusal.to_string().contains("identity_seed"));
let host =
RunHook::parse(r#"{"agent_token":"tok","identity_host_public_key":{"nested":true}}"#)
.expect_err("an object is not a base64 string");
assert_eq!(
host,
RunHookError::IdentityNotAString(crate::identity::HOST_PUBLIC_KEY_KEY)
);
}
#[test]
fn an_unknown_key_is_ignored_rather_than_failing_the_launch() {
let hook = RunHook::parse(r#"{"agent_token":"tok","future_field":{"nested":true}}"#)
.expect("an unknown key does not fail the launch");
assert_eq!(hook.agent_token, "tok");
assert!(hook.env.is_empty());
}
#[test]
fn each_malformed_payload_names_its_own_problem_without_quoting_a_value() {
fn refuses(payload: &str, expected: RunHookError) {
let found = RunHook::parse(payload).expect_err("refused");
assert_eq!(found, expected, "payload {payload}");
}
refuses("not json", RunHookError::NotAnObject);
refuses("[]", RunHookError::NotAnObject);
refuses("{}", RunHookError::TokenMissingOrNotAString);
refuses(
r#"{"agent_token":7}"#,
RunHookError::TokenMissingOrNotAString,
);
refuses(r#"{"agent_token":""}"#, RunHookError::TokenEmpty);
refuses(
r#"{"agent_token":"tok","env":"A=1"}"#,
RunHookError::EnvNotAnObject,
);
refuses(
r#"{"agent_token":"tok","env":["A=1"]}"#,
RunHookError::EnvNotAnObject,
);
refuses(
r#"{"agent_token":"tok","env":{"PORT":8080}}"#,
RunHookError::EnvValueNotAString("PORT".to_string()),
);
let refusal =
RunHook::parse(r#"{"agent_token":"s3cr3t","env":{"PORT":8080}}"#).expect_err("refused");
let message = refusal.to_string();
assert!(!message.contains("s3cr3t"), "{message}");
assert!(message.contains("PORT"), "the key is nameable: {message}");
}
#[test]
fn an_explicitly_null_environment_is_the_same_as_none() {
let hook = RunHook::parse(r#"{"agent_token":"tok","env":null}"#).expect("parses");
assert!(hook.env.is_empty());
}
}