Skip to main content

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(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        // The nested JSON string carries the same private values as RunHook.
35        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/// The caller's own payload, carrying the per-VM secret and an optional launch
45/// environment.
46///
47/// Passing the token at launch is what keeps it out of the shared image snapshot.
48/// It is safe because the platform forwards no external traffic until this hook
49/// returns 200.
50///
51/// `env` rides the same channel because the channel is the only per-VM one the
52/// platform offers, and a launch environment is the same kind of thing as the
53/// token: something a caller knows at launch and cannot bake into a shared image.
54/// The whole payload shares one 4096-byte budget, so a caller who fills `env`
55/// with credentials is spending the token's room; `microvms-core` refuses an
56/// over-budget payload locally, before the call.
57#[derive(Default, Deserialize, JsonSchema, Serialize)]
58pub struct RunHook {
59    pub agent_token: String,
60    /// Base environment applied to every subsequent exec, under the per-request
61    /// `env`. Absent and empty mean the same thing, which is why this is a plain
62    /// map rather than an `Option`: there is nothing a caller could express by
63    /// sending `"env": {}` that omitting the key does not already say.
64    #[serde(default)]
65    pub env: HashMap<String, String>,
66    /// The VM's own identity seed, base64 of 32 bytes, or `None`.
67    ///
68    /// An `Option` rather than a defaulted value because the absence *is*
69    /// information here, unlike `env`: a VM launched without a seed has no key to
70    /// prove anything with, and the tunnel must refuse `--verify-identity` against
71    /// it (close code 4401) rather than downgrade silently. See
72    /// [`super::identity`] for why the material travels on this channel at all.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub identity_seed: Option<String>,
75    /// The launching host's public key, base64 of 32 bytes, or `None`.
76    ///
77    /// The other half of mutual authentication: the daemon pins this, so a
78    /// handshake proves the peer is the host that launched this VM rather than
79    /// anyone who came to hold the agent token. Separate from the seed because a
80    /// caller could deliver one without the other, and the daemon reports which
81    /// half is missing.
82    #[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        // Launch environments may contain credentials, so omit their keys as well as values.
89        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/// Why a `runHookPayload` string could not be read as a [`RunHook`].
105///
106/// A typed error rather than serde's own message, and the reason is the trust
107/// contract rather than tidiness: this payload carries the agent token, and
108/// `serde_json`'s messages quote the offending value. A message that quoted a
109/// value would put payload contents into a log line and a response body, which is
110/// the one thing `docs/TRUST.md` promises never happens. Each variant below names
111/// a *key* or a *shape* and never a value.
112#[derive(Debug, Eq, PartialEq)]
113pub enum RunHookError {
114    /// The payload string parsed as JSON but is not an object, or is not JSON.
115    NotAnObject,
116    /// No `agent_token` key, or one whose value is not a string.
117    TokenMissingOrNotAString,
118    /// `agent_token` is the empty string. Accepting it would install a credential
119    /// every caller can guess.
120    TokenEmpty,
121    /// `env` is present but is not a JSON object.
122    EnvNotAnObject,
123    /// `env` holds a value that is not a string, under this key. Only the key is
124    /// named — a value is where a secret would be.
125    EnvValueNotAString(String),
126    /// An identity key is present but is not a string. Names the key, never the
127    /// value: both identity values are key material.
128    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    /// Reads the caller's payload, ignoring keys this version does not know.
165    ///
166    /// Unknown keys are ignored on purpose: a newer client sending a field this
167    /// daemon has never heard of must still be able to bootstrap it, because the
168    /// alternative is a 400 at the run hook and the platform terminating the VM
169    /// before any traffic is forwarded. Forward compatibility here is the
170    /// difference between an ignored field and a dead launch.
171    ///
172    /// Hand-walked rather than `serde_json::from_str::<RunHook>` so every refusal
173    /// is one of the named variants above. `serde`'s own messages quote values,
174    /// and the values here are secrets.
175    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        // Both identity halves are optional and read the same way, so one closure rather than
203        // two blocks: a second spelling is a second place for the "absent means None, present
204        // but wrong means refuse" distinction to be got wrong.
205        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    /// The envelope key is the platform's camelCase spelling, and getting it wrong
273    /// terminates the VM with a 400 before any traffic is forwarded.
274    #[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    /// An envelope with no payload parses rather than failing: the daemon answers
286    /// 400 with a log line naming the omission, which a parse error could not.
287    #[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    /// The payload every launch before the launch-env feature sent still parses,
294    /// with an empty environment. This is the compatibility floor: a client pinned
295    /// to the old shape must keep bootstrapping.
296    #[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    /// The env map comes through with every pair, and an empty value survives —
304    /// `FOO=` is a variable set to the empty string, which is a different fact
305    /// from `FOO` being unset.
306    #[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    /// The identity halves arrive when sent, and are `None` when not.
316    ///
317    /// The absence is the case that matters: a VM launched without a seed has no key, and the
318    /// tunnel refuses `--verify-identity` against it rather than downgrading. Parsing a
319    /// missing seed as `Some("")` would make that refusal fire as a handshake failure instead,
320    /// which points a reader at the wrong VM.
321    #[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        // An explicit null is the same as absent, for the reason `env: null` is: a generator
335        // that fills unset optional fields with null must not fail the launch.
336        let nulled =
337            RunHook::parse(r#"{"agent_token":"tok","identity_seed":null}"#).expect("parses");
338        assert!(nulled.identity_seed.is_none());
339    }
340
341    /// The struct's field names are the wire keys the identity module declares.
342    ///
343    /// Serde derives the wire spelling from the field name, and the *host* composes its
344    /// payload from [`crate::identity`]'s constants. So a field renamed here without the
345    /// constant — or the reverse — makes the daemon silently read no identity from a payload
346    /// that carries one, and the symptom is close code 4401 against a VM that was launched
347    /// correctly. This asserts the two spellings are one.
348    #[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    /// An identity value of the wrong JSON *type* is refused, naming the key and not the value.
374    #[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    /// An unknown key is ignored rather than refused. A 400 at the run hook makes
394    /// the platform terminate the VM before any traffic is forwarded, so a newer
395    /// client sending a field this daemon has never heard of must still be able to
396    /// bootstrap it.
397    #[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    /// Each malformed shape gets its own named refusal, and none of them quotes a
406    /// value. The payload carries the agent token, so a message that echoed a
407    /// value would publish secret material into a log line.
408    #[test]
409    fn each_malformed_payload_names_its_own_problem_without_quoting_a_value() {
410        /// Asserts one payload's refusal. A helper because `RunHook` itself is not
411        /// `PartialEq` — comparing whole `Result`s would need a derive on a wire
412        /// type for the benefit of one test.
413        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        // The one value in these payloads that is secret-shaped is the token, and
440        // no message may carry it.
441        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    /// `"env": null` is the same as no `env` at all. A generator that fills unset
449    /// optional fields with null is common enough that refusing it would be a 400
450    /// nobody could debug from the outside.
451    #[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}