1use std::collections::HashMap;
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14pub const HOOK_PREFIX: &str = "/aws/lambda-microvms/runtime/v1";
16
17#[derive(Deserialize, JsonSchema, Serialize)]
27pub struct RunHookEnvelope {
28 #[serde(rename = "runHookPayload")]
29 pub run_hook_payload: Option<String>,
30}
31
32impl std::fmt::Debug for RunHookEnvelope {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.debug_struct("RunHookEnvelope")
36 .field(
37 "run_hook_payload",
38 &self.run_hook_payload.as_ref().map(|_| "[REDACTED]"),
39 )
40 .finish()
41 }
42}
43
44#[derive(Default, Deserialize, JsonSchema, Serialize)]
58pub struct RunHook {
59 pub agent_token: String,
60 #[serde(default)]
65 pub env: HashMap<String, String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub identity_seed: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub identity_host_public_key: Option<String>,
84}
85
86impl std::fmt::Debug for RunHook {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("RunHook")
90 .field("agent_token", &"[REDACTED]")
91 .field("env", &"[REDACTED]")
92 .field(
93 "identity_seed",
94 &self.identity_seed.as_ref().map(|_| "[REDACTED]"),
95 )
96 .field(
97 "identity_host_public_key",
98 &self.identity_host_public_key.as_ref().map(|_| "[REDACTED]"),
99 )
100 .finish()
101 }
102}
103
104#[derive(Debug, Eq, PartialEq)]
113pub enum RunHookError {
114 NotAnObject,
116 TokenMissingOrNotAString,
118 TokenEmpty,
121 EnvNotAnObject,
123 EnvValueNotAString(String),
126 IdentityNotAString(&'static str),
129}
130
131impl std::fmt::Display for RunHookError {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 match self {
134 RunHookError::NotAnObject => f.write_str(
135 "runHookPayload is not a JSON object. The platform wraps the string given to \
136 RunMicrovm, so the payload is one parse deeper than the request body: \
137 {\"runHookPayload\": \"{\\\"agent_token\\\": \\\"...\\\"}\"}",
138 ),
139 RunHookError::TokenMissingOrNotAString => {
140 f.write_str("runHookPayload has no agent_token string")
141 }
142 RunHookError::TokenEmpty => f.write_str("agent_token is empty"),
143 RunHookError::EnvNotAnObject => f.write_str(
144 "env must be a JSON object of string keys to string values, and it is not an \
145 object",
146 ),
147 RunHookError::EnvValueNotAString(key) => write!(
148 f,
149 "env[{key:?}] is not a string. Every launch-environment value is a string, \
150 because an environment variable is a string — a number or a nested object \
151 would have to be stringified by someone, and guessing which spelling the \
152 caller meant is how a credential arrives mangled"
153 ),
154 RunHookError::IdentityNotAString(key) => write!(
155 f,
156 "{key} must be a string: standard base64 of exactly 32 bytes. The value is \
157 not quoted here because it is key material"
158 ),
159 }
160 }
161}
162
163impl RunHook {
164 pub fn parse(raw: &str) -> Result<Self, RunHookError> {
176 let value: serde_json::Value =
177 serde_json::from_str(raw).map_err(|_| RunHookError::NotAnObject)?;
178 let object = value.as_object().ok_or(RunHookError::NotAnObject)?;
179
180 let agent_token = object
181 .get("agent_token")
182 .and_then(serde_json::Value::as_str)
183 .ok_or(RunHookError::TokenMissingOrNotAString)?;
184 if agent_token.is_empty() {
185 return Err(RunHookError::TokenEmpty);
186 }
187
188 let mut env = HashMap::new();
189 match object.get("env") {
190 None | Some(serde_json::Value::Null) => {}
191 Some(raw_env) => {
192 let entries = raw_env.as_object().ok_or(RunHookError::EnvNotAnObject)?;
193 for (key, value) in entries {
194 let value = value
195 .as_str()
196 .ok_or_else(|| RunHookError::EnvValueNotAString(key.clone()))?;
197 env.insert(key.clone(), value.to_string());
198 }
199 }
200 }
201
202 let identity_value = |key: &'static str| -> Result<Option<String>, RunHookError> {
206 match object.get(key) {
207 None | Some(serde_json::Value::Null) => Ok(None),
208 Some(serde_json::Value::String(text)) => Ok(Some(text.clone())),
209 Some(_) => Err(RunHookError::IdentityNotAString(key)),
210 }
211 };
212
213 Ok(Self {
214 agent_token: agent_token.to_string(),
215 env,
216 identity_seed: identity_value(crate::identity::SEED_KEY)?,
217 identity_host_public_key: identity_value(crate::identity::HOST_PUBLIC_KEY_KEY)?,
218 })
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn run_hook_debug_retains_field_names_and_omits_private_contents() {
228 let hook = RunHook {
229 agent_token: "example-agent-token-value".into(),
230 env: HashMap::from([(
231 "EXAMPLE_PRIVATE_KEY".into(),
232 "example-environment-value".into(),
233 )]),
234 identity_seed: Some("example-identity-seed-value".into()),
235 identity_host_public_key: Some("example-host-key-value".into()),
236 };
237 for rendered in [format!("{hook:?}"), format!("{hook:#?}")] {
238 assert!(rendered.starts_with("RunHook {"));
239 for field in [
240 "agent_token",
241 "env",
242 "identity_seed",
243 "identity_host_public_key",
244 ] {
245 assert!(rendered.contains(field));
246 }
247 for value in [
248 hook.agent_token.as_str(),
249 "EXAMPLE_PRIVATE_KEY",
250 "example-environment-value",
251 hook.identity_seed.as_deref().unwrap(),
252 hook.identity_host_public_key.as_deref().unwrap(),
253 ] {
254 assert!(!rendered.contains(value));
255 }
256 }
257 }
258
259 #[test]
260 fn run_hook_envelope_debug_omits_the_nested_payload() {
261 let envelope = RunHookEnvelope {
262 run_hook_payload: Some(r#"{"agent_token":"example-nested-token"}"#.into()),
263 };
264 for rendered in [format!("{envelope:?}"), format!("{envelope:#?}")] {
265 assert!(rendered.starts_with("RunHookEnvelope {"));
266 assert!(rendered.contains("run_hook_payload"));
267 assert!(!rendered.contains("example-nested-token"));
268 assert!(!rendered.contains("agent_token"));
269 }
270 }
271
272 #[test]
275 fn the_envelope_key_is_the_platforms_own_camel_case_spelling() {
276 let envelope: RunHookEnvelope =
277 serde_json::from_str(r#"{"runHookPayload":"{}"}"#).expect("deserializes");
278 assert_eq!(envelope.run_hook_payload.as_deref(), Some("{}"));
279 assert_eq!(
280 serde_json::to_string(&envelope).expect("serializes"),
281 r#"{"runHookPayload":"{}"}"#
282 );
283 }
284
285 #[test]
288 fn an_envelope_with_no_payload_parses_as_none() {
289 let envelope: RunHookEnvelope = serde_json::from_str("{}").expect("deserializes");
290 assert!(envelope.run_hook_payload.is_none());
291 }
292
293 #[test]
297 fn a_payload_with_only_a_token_parses_with_an_empty_environment() {
298 let hook = RunHook::parse(r#"{"agent_token":"tok"}"#).expect("parses");
299 assert_eq!(hook.agent_token, "tok");
300 assert!(hook.env.is_empty());
301 }
302
303 #[test]
307 fn a_payload_with_an_environment_carries_every_pair_including_an_empty_value() {
308 let hook =
309 RunHook::parse(r#"{"agent_token":"tok","env":{"A":"1","EMPTY":""}}"#).expect("parses");
310 assert_eq!(hook.env.get("A").map(String::as_str), Some("1"));
311 assert_eq!(hook.env.get("EMPTY").map(String::as_str), Some(""));
312 assert_eq!(hook.env.len(), 2);
313 }
314
315 #[test]
322 fn the_identity_material_parses_when_present_and_is_none_when_absent() {
323 let with = RunHook::parse(
324 r#"{"agent_token":"tok","identity_seed":"c2VlZA==","identity_host_public_key":"aG9zdA=="}"#,
325 )
326 .expect("parses");
327 assert_eq!(with.identity_seed.as_deref(), Some("c2VlZA=="));
328 assert_eq!(with.identity_host_public_key.as_deref(), Some("aG9zdA=="));
329
330 let without = RunHook::parse(r#"{"agent_token":"tok"}"#).expect("parses");
331 assert!(without.identity_seed.is_none());
332 assert!(without.identity_host_public_key.is_none());
333
334 let nulled =
337 RunHook::parse(r#"{"agent_token":"tok","identity_seed":null}"#).expect("parses");
338 assert!(nulled.identity_seed.is_none());
339 }
340
341 #[test]
349 fn the_identity_field_names_are_the_declared_wire_keys() {
350 let hook = RunHook {
351 agent_token: "tok".to_string(),
352 env: HashMap::new(),
353 identity_seed: Some("seed".to_string()),
354 identity_host_public_key: Some("host".to_string()),
355 };
356 let value = serde_json::to_value(&hook).expect("serializes");
357 assert_eq!(
358 value
359 .get(crate::identity::SEED_KEY)
360 .and_then(|v| v.as_str()),
361 Some("seed"),
362 "the seed field must serialize under identity::SEED_KEY"
363 );
364 assert_eq!(
365 value
366 .get(crate::identity::HOST_PUBLIC_KEY_KEY)
367 .and_then(|v| v.as_str()),
368 Some("host"),
369 "the host-key field must serialize under identity::HOST_PUBLIC_KEY_KEY"
370 );
371 }
372
373 #[test]
375 fn an_identity_value_that_is_not_a_string_is_refused_by_key() {
376 let refusal = RunHook::parse(r#"{"agent_token":"tok","identity_seed":7}"#)
377 .expect_err("a number is not a base64 string");
378 assert_eq!(
379 refusal,
380 RunHookError::IdentityNotAString(crate::identity::SEED_KEY)
381 );
382 assert!(refusal.to_string().contains("identity_seed"));
383
384 let host =
385 RunHook::parse(r#"{"agent_token":"tok","identity_host_public_key":{"nested":true}}"#)
386 .expect_err("an object is not a base64 string");
387 assert_eq!(
388 host,
389 RunHookError::IdentityNotAString(crate::identity::HOST_PUBLIC_KEY_KEY)
390 );
391 }
392
393 #[test]
398 fn an_unknown_key_is_ignored_rather_than_failing_the_launch() {
399 let hook = RunHook::parse(r#"{"agent_token":"tok","future_field":{"nested":true}}"#)
400 .expect("an unknown key does not fail the launch");
401 assert_eq!(hook.agent_token, "tok");
402 assert!(hook.env.is_empty());
403 }
404
405 #[test]
409 fn each_malformed_payload_names_its_own_problem_without_quoting_a_value() {
410 fn refuses(payload: &str, expected: RunHookError) {
414 let found = RunHook::parse(payload).expect_err("refused");
415 assert_eq!(found, expected, "payload {payload}");
416 }
417
418 refuses("not json", RunHookError::NotAnObject);
419 refuses("[]", RunHookError::NotAnObject);
420 refuses("{}", RunHookError::TokenMissingOrNotAString);
421 refuses(
422 r#"{"agent_token":7}"#,
423 RunHookError::TokenMissingOrNotAString,
424 );
425 refuses(r#"{"agent_token":""}"#, RunHookError::TokenEmpty);
426 refuses(
427 r#"{"agent_token":"tok","env":"A=1"}"#,
428 RunHookError::EnvNotAnObject,
429 );
430 refuses(
431 r#"{"agent_token":"tok","env":["A=1"]}"#,
432 RunHookError::EnvNotAnObject,
433 );
434 refuses(
435 r#"{"agent_token":"tok","env":{"PORT":8080}}"#,
436 RunHookError::EnvValueNotAString("PORT".to_string()),
437 );
438
439 let refusal =
442 RunHook::parse(r#"{"agent_token":"s3cr3t","env":{"PORT":8080}}"#).expect_err("refused");
443 let message = refusal.to_string();
444 assert!(!message.contains("s3cr3t"), "{message}");
445 assert!(message.contains("PORT"), "the key is nameable: {message}");
446 }
447
448 #[test]
452 fn an_explicitly_null_environment_is_the_same_as_none() {
453 let hook = RunHook::parse(r#"{"agent_token":"tok","env":null}"#).expect("parses");
454 assert!(hook.env.is_empty());
455 }
456}