Skip to main content

microvms_core/
hooks.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The two hook-timeout ceilings, as two types that cannot be swapped.
3//!
4//! Two families of lifecycle hook with ceilings 60x apart:
5//!
6//! * `run`, `resume`, `suspend`, `terminate` — max **60 seconds**. These wait on a
7//!   daemon that is already booted, so 60s is generous and a daemon slower than that
8//!   fails the launch with no way to ask for more time.
9//! * `ready`, `validate` — max **3600 seconds**. These are image-*build* hooks; a
10//!   build hook waits on a Dockerfile.
11//!
12//! Both from the pinned service model (`MicrovmHooks*TimeoutInSecondsInteger` and
13//! `MicrovmImageHooks*TimeoutInSecondsInteger`, API version 2025-09-09), checked
14//! against it by the build gate (TRAP-12).
15//!
16//! # Why two types rather than one with a comment
17//!
18//! Because the trap is the confusion, not the number. A caller who picks one value
19//! large enough for a build hook — 300, say — passes image validation and is rejected
20//! on the run family, *after* the artifact upload, reported as a constraint on a
21//! field they did not know had two different ceilings. The Python client closes this
22//! by validating a dict of hooks against a table of families (`sandbox.py:271`
23//! `require_hook_timeouts_in_range`); that is S2, and it can only fire once a whole
24//! hook block exists.
25//!
26//! Here each ceiling is its own type, so a build timeout in a run-hook field is a
27//! compile error. There is deliberately **no conversion between them** — no `From`,
28//! no `as_run()`, no shared trait with a `secs()` method that a generic function
29//! could use to launder one into the other. A conversion is the whole trap with an
30//! extra step: `BuildHookTimeout::try_new(300)?` is valid, and if it could become a
31//! `RunHookTimeout` the 60s ceiling would be enforced nowhere.
32//!
33//! The only thing both types offer is a way *out* to a plain integer, at the wire
34//! boundary where the request body is built and the field name already says which
35//! family it is.
36
37use std::fmt;
38
39use crate::constants::{
40    MAX_IMAGE_HOOK_TIMEOUT_SEC, MAX_MICROVM_HOOK_TIMEOUT_SEC, MODEL_API_VERSION,
41};
42use crate::error::Error;
43
44/// A timeout for the `run`, `resume`, `suspend`, or `terminate` hook: 1..=60 seconds.
45///
46/// See the module docs for why this cannot be built from a [`BuildHookTimeout`].
47#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
48pub struct RunHookTimeout(u32);
49
50/// A timeout for the `ready` or `validate` image-build hook: 1..=3600 seconds.
51///
52/// See the module docs for why this cannot be built from a [`RunHookTimeout`].
53#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct BuildHookTimeout(u32);
55
56impl RunHookTimeout {
57    /// The service ceiling for the MicroVM hook family.
58    pub const MAX_SECS: u32 = MAX_MICROVM_HOOK_TIMEOUT_SEC;
59
60    /// A run-family timeout, or a local reject naming both ceilings.
61    ///
62    /// The error names the *other* family's ceiling too, because the caller who hits
63    /// this is nearly always someone who picked a build-hook number: telling them 60
64    /// is the limit answers a question they did not ask, and telling them the two
65    /// families differ by 60x answers the one they did.
66    pub fn try_new(secs: u32) -> Result<Self, Error> {
67        if (1..=Self::MAX_SECS).contains(&secs) {
68            return Ok(Self(secs));
69        }
70        Err(Error::invalid_arg(out_of_range_message(
71            "microvmHooks",
72            secs,
73            Self::MAX_SECS,
74            BuildHookTimeout::MAX_SECS,
75        )))
76    }
77
78    /// The value to put on the wire. The only way out of the newtype.
79    pub fn as_secs(self) -> u32 {
80        self.0
81    }
82}
83
84impl BuildHookTimeout {
85    /// The service ceiling for the image-hook family.
86    pub const MAX_SECS: u32 = MAX_IMAGE_HOOK_TIMEOUT_SEC;
87
88    /// A build-family timeout, or a local reject naming both ceilings.
89    pub fn try_new(secs: u32) -> Result<Self, Error> {
90        if (1..=Self::MAX_SECS).contains(&secs) {
91            return Ok(Self(secs));
92        }
93        Err(Error::invalid_arg(out_of_range_message(
94            "microvmImageHooks",
95            secs,
96            Self::MAX_SECS,
97            RunHookTimeout::MAX_SECS,
98        )))
99    }
100
101    /// The value to put on the wire. The only way out of the newtype.
102    pub fn as_secs(self) -> u32 {
103        self.0
104    }
105}
106
107impl fmt::Display for RunHookTimeout {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(f, "{}s", self.0)
110    }
111}
112
113impl fmt::Display for BuildHookTimeout {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        write!(f, "{}s", self.0)
116    }
117}
118
119/// The refusal both constructors raise, naming the family, the ceiling, and the
120/// other family's ceiling.
121///
122/// Shared so the two messages cannot drift apart, and worded from
123/// `sandbox.py:271`'s so a reader who has seen one recognises the other.
124fn out_of_range_message(block: &str, secs: u32, ceiling: u32, other: u32) -> String {
125    format!(
126        "{block} timeout of {secs}s is outside the accepted range 1..{ceiling} (service model \
127         {MODEL_API_VERSION}). The two hook families have ceilings 60x apart — {block} caps at \
128         {ceiling}s while the other family caps at {other}s — because a build hook waits on a \
129         Dockerfile and a run hook waits on a daemon that is already booted \
130         (docs/PLATFORM.md, 'The `runHookPayload` ceiling is 4096 bytes, and the service model \
131         states it')."
132    )
133}
134
135/// A hook port: 1..=65535 (`HooksPortInteger`).
136///
137/// Here rather than in a caller because it is the same shape of constraint from the
138/// same table, and because the daemon's port is a field a caller sets by hand.
139/// Returned as a `u16` since that is what a port is; the guard exists for the two
140/// values a `u16` still gets wrong — 0, and a figure that arrived as a wider integer.
141pub fn require_hook_port(port: u32) -> Result<u16, Error> {
142    if (1..=u32::from(u16::MAX)).contains(&port) {
143        return Ok(port as u16);
144    }
145    Err(Error::invalid_arg(format!(
146        "hooks.port={port} is outside 1..{} (service model {MODEL_API_VERSION}).",
147        u16::MAX
148    )))
149}
150
151#[cfg(test)]
152mod tests {
153    use proptest::prelude::*;
154
155    use super::*;
156    use crate::error::ErrorKind;
157
158    /// The two ceilings, as literals. The 60x gap is the whole reason there are two
159    /// types, so a change to either number is a change to the design.
160    #[test]
161    fn the_two_families_cap_sixty_seconds_apart_by_a_factor_of_sixty() {
162        assert_eq!(RunHookTimeout::MAX_SECS, 60);
163        assert_eq!(BuildHookTimeout::MAX_SECS, 3600);
164        assert_eq!(
165            BuildHookTimeout::MAX_SECS,
166            RunHookTimeout::MAX_SECS * 60,
167            "the 60x gap is the finding"
168        );
169    }
170
171    /// The inclusive boundaries on both sides, for both families. `0` is refused
172    /// because the model's minimum is 1, and a zero timeout is a hook that cannot
173    /// succeed.
174    #[test]
175    fn each_family_accepts_its_ceiling_and_refuses_one_past_it() {
176        assert_eq!(RunHookTimeout::try_new(1).expect("1s fits").as_secs(), 1);
177        assert_eq!(RunHookTimeout::try_new(60).expect("60s fits").as_secs(), 60);
178        assert!(RunHookTimeout::try_new(0).is_err());
179        assert!(RunHookTimeout::try_new(61).is_err());
180
181        assert_eq!(BuildHookTimeout::try_new(1).expect("1s fits").as_secs(), 1);
182        assert_eq!(
183            BuildHookTimeout::try_new(3600)
184                .expect("3600s fits")
185                .as_secs(),
186            3600
187        );
188        assert!(BuildHookTimeout::try_new(0).is_err());
189        assert!(BuildHookTimeout::try_new(3601).is_err());
190    }
191
192    /// The trap, at the value that triggers it. 300 is a plausible build-hook timeout
193    /// and is legal for the image family; the run family refuses it, and the message
194    /// has to explain *why the same number was fine elsewhere* or the caller reads it
195    /// as arbitrary.
196    #[test]
197    fn a_build_sized_timeout_is_refused_by_the_run_family_naming_both_ceilings() {
198        let build = BuildHookTimeout::try_new(300).expect("300s is legal for a build hook");
199        assert_eq!(build.as_secs(), 300);
200
201        let err = RunHookTimeout::try_new(300).expect_err("300s is not legal for a run hook");
202        assert_eq!(err.kind(), ErrorKind::InvalidArg);
203        let message = err.to_string();
204        assert!(message.contains("microvmHooks"), "{message}");
205        assert!(message.contains("1..60"), "{message}");
206        assert!(
207            message.contains("3600s"),
208            "must name the other ceiling: {message}"
209        );
210        assert!(message.contains("60x apart"), "{message}");
211        assert!(
212            message.contains("2025-09-09"),
213            "must name the model: {message}"
214        );
215    }
216
217    /// The image family's refusal names the run family's ceiling, symmetrically. A
218    /// one-directional message would leave the caller who over-shot 3600 without the
219    /// fact that a smaller number is a different family's limit.
220    #[test]
221    fn the_build_family_refusal_names_the_run_ceiling() {
222        let err = BuildHookTimeout::try_new(7200).expect_err("7200s is past the image ceiling");
223        let message = err.to_string();
224        assert!(message.contains("microvmImageHooks"), "{message}");
225        assert!(message.contains("1..3600"), "{message}");
226        assert!(
227            message.contains("60s"),
228            "must name the other ceiling: {message}"
229        );
230    }
231
232    /// A port outside the model's range is refused; the boundaries are inclusive.
233    #[test]
234    fn a_hook_port_outside_the_model_range_is_refused() {
235        assert_eq!(require_hook_port(1).expect("1 fits"), 1);
236        assert_eq!(require_hook_port(9000).expect("9000 fits"), 9000);
237        assert_eq!(require_hook_port(65535).expect("65535 fits"), 65535);
238        for bad in [0, 65536, u32::MAX] {
239            let err = require_hook_port(bad).expect_err("outside 1..65535");
240            assert_eq!(err.kind(), ErrorKind::InvalidArg, "{bad}");
241            assert!(err.to_string().contains("1..65535"), "{bad}");
242        }
243    }
244
245    proptest! {
246        /// The verdict over the whole domain, for both families at once: accepted iff
247        /// in `1..=ceiling`, and a value legal for one family is refused by the other
248        /// for every figure in the 61..=3600 band. That band is the trap's whole
249        /// surface, and a property that only checked each type separately would not
250        /// assert the two disagree.
251        #[test]
252        fn the_two_ceilings_disagree_across_the_whole_sixty_to_thirty_six_hundred_band(secs: u32) {
253            let run = RunHookTimeout::try_new(secs);
254            let build = BuildHookTimeout::try_new(secs);
255
256            prop_assert_eq!(run.is_ok(), (1..=60).contains(&secs));
257            prop_assert_eq!(build.is_ok(), (1..=3600).contains(&secs));
258
259            if (61..=3600).contains(&secs) {
260                prop_assert!(build.is_ok(), "{} is a legal build timeout", secs);
261                let err = run.expect_err("but not a legal run timeout");
262                prop_assert_eq!(err.kind(), ErrorKind::InvalidArg);
263            }
264            if let Ok(run) = RunHookTimeout::try_new(secs) {
265                prop_assert_eq!(run.as_secs(), secs);
266            }
267            if let Ok(build) = BuildHookTimeout::try_new(secs) {
268                prop_assert_eq!(build.as_secs(), secs);
269            }
270        }
271    }
272}