use std::collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const HOOK_PREFIX: &str = "/aws/lambda-microvms/runtime/v1";
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct RunHookEnvelope {
#[serde(rename = "runHookPayload")]
pub run_hook_payload: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct RunHook {
pub agent_token: String,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Eq, PartialEq)]
pub enum RunHookError {
NotAnObject,
TokenMissingOrNotAString,
TokenEmpty,
EnvNotAnObject,
EnvValueNotAString(String),
}
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"
),
}
}
}
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());
}
}
}
Ok(Self {
agent_token: agent_token.to_string(),
env,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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 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());
}
}