microvms_protocol/hook.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Lifecycle-hook wire types and the prefix the platform calls them under.
3//!
4//! Not client types. A consumer must never post to these paths — they are the
5//! platform's, and `/run` in particular is the one route whose success is not
6//! repeatable — but the shapes belong here anyway, because a client generator
7//! reading the schema has to be told what the daemon accepts on them.
8
9use std::collections::HashMap;
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14/// Prefix the platform uses for every lifecycle hook. Fixed by the service.
15pub const HOOK_PREFIX: &str = "/aws/lambda-microvms/runtime/v1";
16
17/// The envelope the platform posts to the run hook.
18///
19/// The `runHookPayload` string given to `RunMicrovm` is not delivered as the
20/// request body: the platform wraps it, so the body is
21/// `{"runHookPayload": "<the caller's string>"}` and the caller's own JSON is one
22/// `serde_json` parse deeper. Measured 2026-08-05 — a daemon that reads
23/// `agent_token` from the top level answers 400, and the platform then terminates
24/// the VM with "Run lifecycle hook returned HTTP status 400" before any traffic is
25/// forwarded, so the mistake is invisible from the outside.
26#[derive(Debug, Deserialize, JsonSchema, Serialize)]
27pub struct RunHookEnvelope {
28 #[serde(rename = "runHookPayload")]
29 pub run_hook_payload: Option<String>,
30}
31
32/// The caller's own payload, carrying the per-VM secret and an optional launch
33/// environment.
34///
35/// Passing the token at launch is what keeps it out of the shared image snapshot.
36/// It is safe because the platform forwards no external traffic until this hook
37/// returns 200.
38///
39/// `env` rides the same channel because the channel is the only per-VM one the
40/// platform offers, and a launch environment is the same kind of thing as the
41/// token: something a caller knows at launch and cannot bake into a shared image.
42/// The whole payload shares one 4096-byte budget, so a caller who fills `env`
43/// with credentials is spending the token's room; `microvms-core` refuses an
44/// over-budget payload locally, before the call.
45#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
46pub struct RunHook {
47 pub agent_token: String,
48 /// Base environment applied to every subsequent exec, under the per-request
49 /// `env`. Absent and empty mean the same thing, which is why this is a plain
50 /// map rather than an `Option`: there is nothing a caller could express by
51 /// sending `"env": {}` that omitting the key does not already say.
52 #[serde(default)]
53 pub env: HashMap<String, String>,
54 /// The VM's own identity seed, base64 of 32 bytes, or `None`.
55 ///
56 /// An `Option` rather than a defaulted value because the absence *is*
57 /// information here, unlike `env`: a VM launched without a seed has no key to
58 /// prove anything with, and the tunnel must refuse `--verify-identity` against
59 /// it (close code 4401) rather than downgrade silently. See
60 /// [`super::identity`] for why the material travels on this channel at all.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub identity_seed: Option<String>,
63 /// The launching host's public key, base64 of 32 bytes, or `None`.
64 ///
65 /// The other half of mutual authentication: the daemon pins this, so a
66 /// handshake proves the peer is the host that launched this VM rather than
67 /// anyone who came to hold the agent token. Separate from the seed because a
68 /// caller could deliver one without the other, and the daemon reports which
69 /// half is missing.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub identity_host_public_key: Option<String>,
72}
73
74/// Why a `runHookPayload` string could not be read as a [`RunHook`].
75///
76/// A typed error rather than serde's own message, and the reason is the trust
77/// contract rather than tidiness: this payload carries the agent token, and
78/// `serde_json`'s messages quote the offending value. A message that quoted a
79/// value would put payload contents into a log line and a response body, which is
80/// the one thing `docs/TRUST.md` promises never happens. Each variant below names
81/// a *key* or a *shape* and never a value.
82#[derive(Debug, Eq, PartialEq)]
83pub enum RunHookError {
84 /// The payload string parsed as JSON but is not an object, or is not JSON.
85 NotAnObject,
86 /// No `agent_token` key, or one whose value is not a string.
87 TokenMissingOrNotAString,
88 /// `agent_token` is the empty string. Accepting it would install a credential
89 /// every caller can guess.
90 TokenEmpty,
91 /// `env` is present but is not a JSON object.
92 EnvNotAnObject,
93 /// `env` holds a value that is not a string, under this key. Only the key is
94 /// named — a value is where a secret would be.
95 EnvValueNotAString(String),
96 /// An identity key is present but is not a string. Names the key, never the
97 /// value: both identity values are key material.
98 IdentityNotAString(&'static str),
99}
100
101impl std::fmt::Display for RunHookError {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 RunHookError::NotAnObject => f.write_str(
105 "runHookPayload is not a JSON object. The platform wraps the string given to \
106 RunMicrovm, so the payload is one parse deeper than the request body: \
107 {\"runHookPayload\": \"{\\\"agent_token\\\": \\\"...\\\"}\"}",
108 ),
109 RunHookError::TokenMissingOrNotAString => {
110 f.write_str("runHookPayload has no agent_token string")
111 }
112 RunHookError::TokenEmpty => f.write_str("agent_token is empty"),
113 RunHookError::EnvNotAnObject => f.write_str(
114 "env must be a JSON object of string keys to string values, and it is not an \
115 object",
116 ),
117 RunHookError::EnvValueNotAString(key) => write!(
118 f,
119 "env[{key:?}] is not a string. Every launch-environment value is a string, \
120 because an environment variable is a string — a number or a nested object \
121 would have to be stringified by someone, and guessing which spelling the \
122 caller meant is how a credential arrives mangled"
123 ),
124 RunHookError::IdentityNotAString(key) => write!(
125 f,
126 "{key} must be a string: standard base64 of exactly 32 bytes. The value is \
127 not quoted here because it is key material"
128 ),
129 }
130 }
131}
132
133impl RunHook {
134 /// Reads the caller's payload, ignoring keys this version does not know.
135 ///
136 /// Unknown keys are ignored on purpose: a newer client sending a field this
137 /// daemon has never heard of must still be able to bootstrap it, because the
138 /// alternative is a 400 at the run hook and the platform terminating the VM
139 /// before any traffic is forwarded. Forward compatibility here is the
140 /// difference between an ignored field and a dead launch.
141 ///
142 /// Hand-walked rather than `serde_json::from_str::<RunHook>` so every refusal
143 /// is one of the named variants above. `serde`'s own messages quote values,
144 /// and the values here are secrets.
145 pub fn parse(raw: &str) -> Result<Self, RunHookError> {
146 let value: serde_json::Value =
147 serde_json::from_str(raw).map_err(|_| RunHookError::NotAnObject)?;
148 let object = value.as_object().ok_or(RunHookError::NotAnObject)?;
149
150 let agent_token = object
151 .get("agent_token")
152 .and_then(serde_json::Value::as_str)
153 .ok_or(RunHookError::TokenMissingOrNotAString)?;
154 if agent_token.is_empty() {
155 return Err(RunHookError::TokenEmpty);
156 }
157
158 let mut env = HashMap::new();
159 match object.get("env") {
160 None | Some(serde_json::Value::Null) => {}
161 Some(raw_env) => {
162 let entries = raw_env.as_object().ok_or(RunHookError::EnvNotAnObject)?;
163 for (key, value) in entries {
164 let value = value
165 .as_str()
166 .ok_or_else(|| RunHookError::EnvValueNotAString(key.clone()))?;
167 env.insert(key.clone(), value.to_string());
168 }
169 }
170 }
171
172 // Both identity halves are optional and read the same way, so one closure rather than
173 // two blocks: a second spelling is a second place for the "absent means None, present
174 // but wrong means refuse" distinction to be got wrong.
175 let identity_value = |key: &'static str| -> Result<Option<String>, RunHookError> {
176 match object.get(key) {
177 None | Some(serde_json::Value::Null) => Ok(None),
178 Some(serde_json::Value::String(text)) => Ok(Some(text.clone())),
179 Some(_) => Err(RunHookError::IdentityNotAString(key)),
180 }
181 };
182
183 Ok(Self {
184 agent_token: agent_token.to_string(),
185 env,
186 identity_seed: identity_value(crate::identity::SEED_KEY)?,
187 identity_host_public_key: identity_value(crate::identity::HOST_PUBLIC_KEY_KEY)?,
188 })
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 /// The envelope key is the platform's camelCase spelling, and getting it wrong
197 /// terminates the VM with a 400 before any traffic is forwarded.
198 #[test]
199 fn the_envelope_key_is_the_platforms_own_camel_case_spelling() {
200 let envelope: RunHookEnvelope =
201 serde_json::from_str(r#"{"runHookPayload":"{}"}"#).expect("deserializes");
202 assert_eq!(envelope.run_hook_payload.as_deref(), Some("{}"));
203 assert_eq!(
204 serde_json::to_string(&envelope).expect("serializes"),
205 r#"{"runHookPayload":"{}"}"#
206 );
207 }
208
209 /// An envelope with no payload parses rather than failing: the daemon answers
210 /// 400 with a log line naming the omission, which a parse error could not.
211 #[test]
212 fn an_envelope_with_no_payload_parses_as_none() {
213 let envelope: RunHookEnvelope = serde_json::from_str("{}").expect("deserializes");
214 assert!(envelope.run_hook_payload.is_none());
215 }
216
217 /// The payload every launch before the launch-env feature sent still parses,
218 /// with an empty environment. This is the compatibility floor: a client pinned
219 /// to the old shape must keep bootstrapping.
220 #[test]
221 fn a_payload_with_only_a_token_parses_with_an_empty_environment() {
222 let hook = RunHook::parse(r#"{"agent_token":"tok"}"#).expect("parses");
223 assert_eq!(hook.agent_token, "tok");
224 assert!(hook.env.is_empty());
225 }
226
227 /// The env map comes through with every pair, and an empty value survives —
228 /// `FOO=` is a variable set to the empty string, which is a different fact
229 /// from `FOO` being unset.
230 #[test]
231 fn a_payload_with_an_environment_carries_every_pair_including_an_empty_value() {
232 let hook =
233 RunHook::parse(r#"{"agent_token":"tok","env":{"A":"1","EMPTY":""}}"#).expect("parses");
234 assert_eq!(hook.env.get("A").map(String::as_str), Some("1"));
235 assert_eq!(hook.env.get("EMPTY").map(String::as_str), Some(""));
236 assert_eq!(hook.env.len(), 2);
237 }
238
239 /// The identity halves arrive when sent, and are `None` when not.
240 ///
241 /// The absence is the case that matters: a VM launched without a seed has no key, and the
242 /// tunnel refuses `--verify-identity` against it rather than downgrading. Parsing a
243 /// missing seed as `Some("")` would make that refusal fire as a handshake failure instead,
244 /// which points a reader at the wrong VM.
245 #[test]
246 fn the_identity_material_parses_when_present_and_is_none_when_absent() {
247 let with = RunHook::parse(
248 r#"{"agent_token":"tok","identity_seed":"c2VlZA==","identity_host_public_key":"aG9zdA=="}"#,
249 )
250 .expect("parses");
251 assert_eq!(with.identity_seed.as_deref(), Some("c2VlZA=="));
252 assert_eq!(with.identity_host_public_key.as_deref(), Some("aG9zdA=="));
253
254 let without = RunHook::parse(r#"{"agent_token":"tok"}"#).expect("parses");
255 assert!(without.identity_seed.is_none());
256 assert!(without.identity_host_public_key.is_none());
257
258 // An explicit null is the same as absent, for the reason `env: null` is: a generator
259 // that fills unset optional fields with null must not fail the launch.
260 let nulled =
261 RunHook::parse(r#"{"agent_token":"tok","identity_seed":null}"#).expect("parses");
262 assert!(nulled.identity_seed.is_none());
263 }
264
265 /// The struct's field names are the wire keys the identity module declares.
266 ///
267 /// Serde derives the wire spelling from the field name, and the *host* composes its
268 /// payload from [`crate::identity`]'s constants. So a field renamed here without the
269 /// constant — or the reverse — makes the daemon silently read no identity from a payload
270 /// that carries one, and the symptom is close code 4401 against a VM that was launched
271 /// correctly. This asserts the two spellings are one.
272 #[test]
273 fn the_identity_field_names_are_the_declared_wire_keys() {
274 let hook = RunHook {
275 agent_token: "tok".to_string(),
276 env: HashMap::new(),
277 identity_seed: Some("seed".to_string()),
278 identity_host_public_key: Some("host".to_string()),
279 };
280 let value = serde_json::to_value(&hook).expect("serializes");
281 assert_eq!(
282 value
283 .get(crate::identity::SEED_KEY)
284 .and_then(|v| v.as_str()),
285 Some("seed"),
286 "the seed field must serialize under identity::SEED_KEY"
287 );
288 assert_eq!(
289 value
290 .get(crate::identity::HOST_PUBLIC_KEY_KEY)
291 .and_then(|v| v.as_str()),
292 Some("host"),
293 "the host-key field must serialize under identity::HOST_PUBLIC_KEY_KEY"
294 );
295 }
296
297 /// An identity value of the wrong JSON *type* is refused, naming the key and not the value.
298 #[test]
299 fn an_identity_value_that_is_not_a_string_is_refused_by_key() {
300 let refusal = RunHook::parse(r#"{"agent_token":"tok","identity_seed":7}"#)
301 .expect_err("a number is not a base64 string");
302 assert_eq!(
303 refusal,
304 RunHookError::IdentityNotAString(crate::identity::SEED_KEY)
305 );
306 assert!(refusal.to_string().contains("identity_seed"));
307
308 let host =
309 RunHook::parse(r#"{"agent_token":"tok","identity_host_public_key":{"nested":true}}"#)
310 .expect_err("an object is not a base64 string");
311 assert_eq!(
312 host,
313 RunHookError::IdentityNotAString(crate::identity::HOST_PUBLIC_KEY_KEY)
314 );
315 }
316
317 /// An unknown key is ignored rather than refused. A 400 at the run hook makes
318 /// the platform terminate the VM before any traffic is forwarded, so a newer
319 /// client sending a field this daemon has never heard of must still be able to
320 /// bootstrap it.
321 #[test]
322 fn an_unknown_key_is_ignored_rather_than_failing_the_launch() {
323 let hook = RunHook::parse(r#"{"agent_token":"tok","future_field":{"nested":true}}"#)
324 .expect("an unknown key does not fail the launch");
325 assert_eq!(hook.agent_token, "tok");
326 assert!(hook.env.is_empty());
327 }
328
329 /// Each malformed shape gets its own named refusal, and none of them quotes a
330 /// value. The payload carries the agent token, so a message that echoed a
331 /// value would publish secret material into a log line.
332 #[test]
333 fn each_malformed_payload_names_its_own_problem_without_quoting_a_value() {
334 /// Asserts one payload's refusal. A helper because `RunHook` itself is not
335 /// `PartialEq` — comparing whole `Result`s would need a derive on a wire
336 /// type for the benefit of one test.
337 fn refuses(payload: &str, expected: RunHookError) {
338 let found = RunHook::parse(payload).expect_err("refused");
339 assert_eq!(found, expected, "payload {payload}");
340 }
341
342 refuses("not json", RunHookError::NotAnObject);
343 refuses("[]", RunHookError::NotAnObject);
344 refuses("{}", RunHookError::TokenMissingOrNotAString);
345 refuses(
346 r#"{"agent_token":7}"#,
347 RunHookError::TokenMissingOrNotAString,
348 );
349 refuses(r#"{"agent_token":""}"#, RunHookError::TokenEmpty);
350 refuses(
351 r#"{"agent_token":"tok","env":"A=1"}"#,
352 RunHookError::EnvNotAnObject,
353 );
354 refuses(
355 r#"{"agent_token":"tok","env":["A=1"]}"#,
356 RunHookError::EnvNotAnObject,
357 );
358 refuses(
359 r#"{"agent_token":"tok","env":{"PORT":8080}}"#,
360 RunHookError::EnvValueNotAString("PORT".to_string()),
361 );
362
363 // The one value in these payloads that is secret-shaped is the token, and
364 // no message may carry it.
365 let refusal =
366 RunHook::parse(r#"{"agent_token":"s3cr3t","env":{"PORT":8080}}"#).expect_err("refused");
367 let message = refusal.to_string();
368 assert!(!message.contains("s3cr3t"), "{message}");
369 assert!(message.contains("PORT"), "the key is nameable: {message}");
370 }
371
372 /// `"env": null` is the same as no `env` at all. A generator that fills unset
373 /// optional fields with null is common enough that refusing it would be a 400
374 /// nobody could debug from the outside.
375 #[test]
376 fn an_explicitly_null_environment_is_the_same_as_none() {
377 let hook = RunHook::parse(r#"{"agent_token":"tok","env":null}"#).expect("parses");
378 assert!(hook.env.is_empty());
379 }
380}