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(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}
55
56/// Why a `runHookPayload` string could not be read as a [`RunHook`].
57///
58/// A typed error rather than serde's own message, and the reason is the trust
59/// contract rather than tidiness: this payload carries the agent token, and
60/// `serde_json`'s messages quote the offending value. A message that quoted a
61/// value would put payload contents into a log line and a response body, which is
62/// the one thing `docs/TRUST.md` promises never happens. Each variant below names
63/// a *key* or a *shape* and never a value.
64#[derive(Debug, Eq, PartialEq)]
65pub enum RunHookError {
66    /// The payload string parsed as JSON but is not an object, or is not JSON.
67    NotAnObject,
68    /// No `agent_token` key, or one whose value is not a string.
69    TokenMissingOrNotAString,
70    /// `agent_token` is the empty string. Accepting it would install a credential
71    /// every caller can guess.
72    TokenEmpty,
73    /// `env` is present but is not a JSON object.
74    EnvNotAnObject,
75    /// `env` holds a value that is not a string, under this key. Only the key is
76    /// named — a value is where a secret would be.
77    EnvValueNotAString(String),
78}
79
80impl std::fmt::Display for RunHookError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            RunHookError::NotAnObject => f.write_str(
84                "runHookPayload is not a JSON object. The platform wraps the string given to \
85                 RunMicrovm, so the payload is one parse deeper than the request body: \
86                 {\"runHookPayload\": \"{\\\"agent_token\\\": \\\"...\\\"}\"}",
87            ),
88            RunHookError::TokenMissingOrNotAString => {
89                f.write_str("runHookPayload has no agent_token string")
90            }
91            RunHookError::TokenEmpty => f.write_str("agent_token is empty"),
92            RunHookError::EnvNotAnObject => f.write_str(
93                "env must be a JSON object of string keys to string values, and it is not an \
94                 object",
95            ),
96            RunHookError::EnvValueNotAString(key) => write!(
97                f,
98                "env[{key:?}] is not a string. Every launch-environment value is a string, \
99                 because an environment variable is a string — a number or a nested object \
100                 would have to be stringified by someone, and guessing which spelling the \
101                 caller meant is how a credential arrives mangled"
102            ),
103        }
104    }
105}
106
107impl RunHook {
108    /// Reads the caller's payload, ignoring keys this version does not know.
109    ///
110    /// Unknown keys are ignored on purpose: a newer client sending a field this
111    /// daemon has never heard of must still be able to bootstrap it, because the
112    /// alternative is a 400 at the run hook and the platform terminating the VM
113    /// before any traffic is forwarded. Forward compatibility here is the
114    /// difference between an ignored field and a dead launch.
115    ///
116    /// Hand-walked rather than `serde_json::from_str::<RunHook>` so every refusal
117    /// is one of the named variants above. `serde`'s own messages quote values,
118    /// and the values here are secrets.
119    pub fn parse(raw: &str) -> Result<Self, RunHookError> {
120        let value: serde_json::Value =
121            serde_json::from_str(raw).map_err(|_| RunHookError::NotAnObject)?;
122        let object = value.as_object().ok_or(RunHookError::NotAnObject)?;
123
124        let agent_token = object
125            .get("agent_token")
126            .and_then(serde_json::Value::as_str)
127            .ok_or(RunHookError::TokenMissingOrNotAString)?;
128        if agent_token.is_empty() {
129            return Err(RunHookError::TokenEmpty);
130        }
131
132        let mut env = HashMap::new();
133        match object.get("env") {
134            None | Some(serde_json::Value::Null) => {}
135            Some(raw_env) => {
136                let entries = raw_env.as_object().ok_or(RunHookError::EnvNotAnObject)?;
137                for (key, value) in entries {
138                    let value = value
139                        .as_str()
140                        .ok_or_else(|| RunHookError::EnvValueNotAString(key.clone()))?;
141                    env.insert(key.clone(), value.to_string());
142                }
143            }
144        }
145
146        Ok(Self {
147            agent_token: agent_token.to_string(),
148            env,
149        })
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    /// The envelope key is the platform's camelCase spelling, and getting it wrong
158    /// terminates the VM with a 400 before any traffic is forwarded.
159    #[test]
160    fn the_envelope_key_is_the_platforms_own_camel_case_spelling() {
161        let envelope: RunHookEnvelope =
162            serde_json::from_str(r#"{"runHookPayload":"{}"}"#).expect("deserializes");
163        assert_eq!(envelope.run_hook_payload.as_deref(), Some("{}"));
164        assert_eq!(
165            serde_json::to_string(&envelope).expect("serializes"),
166            r#"{"runHookPayload":"{}"}"#
167        );
168    }
169
170    /// An envelope with no payload parses rather than failing: the daemon answers
171    /// 400 with a log line naming the omission, which a parse error could not.
172    #[test]
173    fn an_envelope_with_no_payload_parses_as_none() {
174        let envelope: RunHookEnvelope = serde_json::from_str("{}").expect("deserializes");
175        assert!(envelope.run_hook_payload.is_none());
176    }
177
178    /// The payload every launch before the launch-env feature sent still parses,
179    /// with an empty environment. This is the compatibility floor: a client pinned
180    /// to the old shape must keep bootstrapping.
181    #[test]
182    fn a_payload_with_only_a_token_parses_with_an_empty_environment() {
183        let hook = RunHook::parse(r#"{"agent_token":"tok"}"#).expect("parses");
184        assert_eq!(hook.agent_token, "tok");
185        assert!(hook.env.is_empty());
186    }
187
188    /// The env map comes through with every pair, and an empty value survives —
189    /// `FOO=` is a variable set to the empty string, which is a different fact
190    /// from `FOO` being unset.
191    #[test]
192    fn a_payload_with_an_environment_carries_every_pair_including_an_empty_value() {
193        let hook =
194            RunHook::parse(r#"{"agent_token":"tok","env":{"A":"1","EMPTY":""}}"#).expect("parses");
195        assert_eq!(hook.env.get("A").map(String::as_str), Some("1"));
196        assert_eq!(hook.env.get("EMPTY").map(String::as_str), Some(""));
197        assert_eq!(hook.env.len(), 2);
198    }
199
200    /// An unknown key is ignored rather than refused. A 400 at the run hook makes
201    /// the platform terminate the VM before any traffic is forwarded, so a newer
202    /// client sending a field this daemon has never heard of must still be able to
203    /// bootstrap it.
204    #[test]
205    fn an_unknown_key_is_ignored_rather_than_failing_the_launch() {
206        let hook = RunHook::parse(r#"{"agent_token":"tok","future_field":{"nested":true}}"#)
207            .expect("an unknown key does not fail the launch");
208        assert_eq!(hook.agent_token, "tok");
209        assert!(hook.env.is_empty());
210    }
211
212    /// Each malformed shape gets its own named refusal, and none of them quotes a
213    /// value. The payload carries the agent token, so a message that echoed a
214    /// value would publish secret material into a log line.
215    #[test]
216    fn each_malformed_payload_names_its_own_problem_without_quoting_a_value() {
217        /// Asserts one payload's refusal. A helper because `RunHook` itself is not
218        /// `PartialEq` — comparing whole `Result`s would need a derive on a wire
219        /// type for the benefit of one test.
220        fn refuses(payload: &str, expected: RunHookError) {
221            let found = RunHook::parse(payload).expect_err("refused");
222            assert_eq!(found, expected, "payload {payload}");
223        }
224
225        refuses("not json", RunHookError::NotAnObject);
226        refuses("[]", RunHookError::NotAnObject);
227        refuses("{}", RunHookError::TokenMissingOrNotAString);
228        refuses(
229            r#"{"agent_token":7}"#,
230            RunHookError::TokenMissingOrNotAString,
231        );
232        refuses(r#"{"agent_token":""}"#, RunHookError::TokenEmpty);
233        refuses(
234            r#"{"agent_token":"tok","env":"A=1"}"#,
235            RunHookError::EnvNotAnObject,
236        );
237        refuses(
238            r#"{"agent_token":"tok","env":["A=1"]}"#,
239            RunHookError::EnvNotAnObject,
240        );
241        refuses(
242            r#"{"agent_token":"tok","env":{"PORT":8080}}"#,
243            RunHookError::EnvValueNotAString("PORT".to_string()),
244        );
245
246        // The one value in these payloads that is secret-shaped is the token, and
247        // no message may carry it.
248        let refusal =
249            RunHook::parse(r#"{"agent_token":"s3cr3t","env":{"PORT":8080}}"#).expect_err("refused");
250        let message = refusal.to_string();
251        assert!(!message.contains("s3cr3t"), "{message}");
252        assert!(message.contains("PORT"), "the key is nameable: {message}");
253    }
254
255    /// `"env": null` is the same as no `env` at all. A generator that fills unset
256    /// optional fields with null is common enough that refusing it would be a 400
257    /// nobody could debug from the outside.
258    #[test]
259    fn an_explicitly_null_environment_is_the_same_as_none() {
260        let hook = RunHook::parse(r#"{"agent_token":"tok","env":null}"#).expect("parses");
261        assert!(hook.env.is_empty());
262    }
263}