Skip to main content

detcore/
record_or_replay.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9use reverie::Error;
10use reverie::Guest;
11use reverie::Subscription;
12use reverie::Tool;
13use reverie::syscalls::Syscall;
14use serde::Deserialize;
15use serde::Serialize;
16
17use crate::config::Config;
18use crate::tool_global::GlobalState;
19
20/// Helper trait.
21pub trait RecordOrReplay: Tool<GlobalState = GlobalState> {}
22
23impl<T> RecordOrReplay for T where T: Tool<GlobalState = GlobalState> {}
24
25/// Binary streams do not carry the reserved-input provenance required by
26/// Chosen execution. This is an admission refusal, never a guest errno or
27/// a replay desync; the typed inner Tool is authoritative even when Config's
28/// mode booleans are false.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ChosenBinaryInputRefusal;
31
32impl std::fmt::Display for ChosenBinaryInputRefusal {
33    fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        output.write_str(
35            "HERMIT_CHOSEN_BINARY_INPUT_PROVENANCE: Chosen guest IDs require reserved-input provenance, which the binary record/replay stream does not carry; the guest has not started. Use Namespace guest IDs for binary record/replay, or use plain Chosen execution and its qualified JSON schedule recording. Unknown custom Tool compositions must declare their actual plain stream role; Config flags cannot override a recorder or replayer.",
36        )
37    }
38}
39
40impl std::error::Error for ChosenBinaryInputRefusal {}
41
42/// Pure decision used by the operational static facility admission hook and
43/// typed public launch wrappers. Calling subscriptions is only a query and
44/// must never execute this policy's exit path.
45pub fn require_chosen_input_stream(
46    config: &Config,
47    role: reverie::ToolStreamRole,
48) -> Result<(), ChosenBinaryInputRefusal> {
49    if matches!(
50        config.guest_ids,
51        detcore_model::pid::GuestIds::Chosen { .. }
52    ) && (role != reverie::ToolStreamRole::Plain
53        || config.recordreplay_modes
54        || config.replaying)
55    {
56        return Err(ChosenBinaryInputRefusal);
57    }
58    Ok(())
59}
60
61/// Operational pre-start admission for raw embedding entrypoints. These have
62/// no CLI error classifier, so retain the existing policy-refusal exit 122.
63/// Pure Tool queries must use neither this function nor another exit path.
64pub fn admit_chosen_input_stream(config: &Config, role: reverie::ToolStreamRole) {
65    if let Err(refusal) = require_chosen_input_stream(config, role) {
66        crate::scheduler::exit_on_scheduler_refusal(&refusal);
67    }
68}
69
70/// Whether the record or replay tool `T` is [`NoopTool`], whose
71/// `handle_syscall_event` does nothing but inject the call it receives and
72/// return the injection's result, unchanged, with no other effect. Detcore
73/// then injects a forwarded call itself rather than through the tool: the
74/// same thing, without the two boxed futures (the tool's handler and
75/// `IntoGuest`'s `inject`) that routing it through the tool costs on every
76/// forwarded syscall. Every other tool, including a custom one, still
77/// handles each forwarded call itself. This names a record or replay tool,
78/// never a backend, and leaves `RecordOrReplay` exactly as it was.
79///
80/// `T` is recognised by its type name (`detcore::record_or_replay::NoopTool`),
81/// among zero-sized types, because `TypeId` would need a `T: 'static` bound
82/// on Detcore's generic code, and adding one changes Detcore's public API.
83///
84/// Known limitation, accepted: a type name is not unique across crates. A
85/// zero-sized record or replay tool defined at `record_or_replay::NoopTool`
86/// in another library whose crate name is also `detcore` (`[lib] name =
87/// "detcore"`) has the same name. If such a tool did more than inject, its
88/// `handle_syscall_event` would be bypassed and every call Detcore forwards
89/// would be injected directly instead. Detcore has no consumers outside the
90/// Hermit workspace, and a collision needs a crate that deliberately takes
91/// Detcore's crate name and module path, so this is declared rather than
92/// prevented with an API change.
93pub(crate) fn injects_only<T>() -> bool {
94    std::mem::size_of::<T>() == 0 && std::any::type_name::<T>() == std::any::type_name::<NoopTool>()
95}
96
97/// A tool that only injects the syscall it receives.
98#[derive(Debug, Default, Serialize, Deserialize)]
99pub struct NoopTool;
100
101#[reverie::tool]
102impl Tool for NoopTool {
103    type GlobalState = GlobalState;
104    type ThreadState = ();
105
106    fn stream_role() -> reverie::ToolStreamRole {
107        reverie::ToolStreamRole::Plain
108    }
109
110    fn native_mm_ownership(_cfg: &Config) -> reverie::NativeMmOwnership {
111        // This Tool has no worker, codec state, or FFI construction: it only
112        // delegates the intercepted operation to the managed Guest.
113        reverie::NativeMmOwnership::ManagedRuntimeOnly
114    }
115
116    fn subscriptions(_cfg: &Config) -> Subscription {
117        // Don't subscribe to anything by default. This noop-tool doesn't care
118        // about any syscalls and will be ORed with the detcore subscriptions.
119        Subscription::none()
120    }
121
122    async fn handle_syscall_event<T: Guest<Self>>(
123        &self,
124        guest: &mut T,
125        call: Syscall,
126    ) -> Result<i64, Error> {
127        // NOTE: Cannot use tail_inject here as that would prevent any detcore
128        // post-hook code from running.
129        Ok(guest.inject(call).await?)
130    }
131}
132#[cfg(test)]
133mod mapping_capture_tests;
134
135#[cfg(test)]
136mod input_stream_tests {
137    use super::*;
138
139    #[test]
140    fn chosen_binary_refusal_uses_the_typed_stream_even_with_false_mode_flags() {
141        let config = Config {
142            guest_ids: detcore_model::pid::GuestIds::Chosen {
143                base: 5000,
144                refuse_host_ids: true,
145            },
146            recordreplay_modes: false,
147            replaying: false,
148            ..Config::default()
149        };
150        assert_eq!(NoopTool::stream_role(), reverie::ToolStreamRole::Plain);
151        require_chosen_input_stream(&config, NoopTool::stream_role()).unwrap();
152        for role in [
153            reverie::ToolStreamRole::BinaryRecord,
154            reverie::ToolStreamRole::BinaryReplay,
155            reverie::ToolStreamRole::Unknown,
156        ] {
157            let refusal = require_chosen_input_stream(&config, role).unwrap_err();
158            assert!(
159                refusal
160                    .to_string()
161                    .starts_with("HERMIT_CHOSEN_BINARY_INPUT_PROVENANCE:")
162            );
163        }
164        for (recordreplay_modes, replaying) in [(true, false), (false, true), (true, true)] {
165            let config = Config {
166                recordreplay_modes,
167                replaying,
168                ..config.clone()
169            };
170            assert!(require_chosen_input_stream(&config, reverie::ToolStreamRole::Plain).is_err());
171        }
172    }
173
174    #[test]
175    fn namespace_stream_admission_and_subscription_queries_remain_unconditional() {
176        for role in [
177            reverie::ToolStreamRole::BinaryRecord,
178            reverie::ToolStreamRole::BinaryReplay,
179            reverie::ToolStreamRole::Unknown,
180            reverie::ToolStreamRole::Plain,
181        ] {
182            for (recordreplay_modes, replaying) in
183                [(false, false), (true, false), (false, true), (true, true)]
184            {
185                require_chosen_input_stream(
186                    &Config {
187                        recordreplay_modes,
188                        replaying,
189                        ..Config::default()
190                    },
191                    role,
192                )
193                .unwrap();
194            }
195        }
196        // Querying a forbidden configuration is legal: only the operational
197        // facility admission hook may refuse it.
198        let config = Config {
199            guest_ids: detcore_model::pid::GuestIds::Chosen {
200                base: 5000,
201                refuse_host_ids: true,
202            },
203            recordreplay_modes: true,
204            ..Config::default()
205        };
206        let _ = <crate::Detcore as Tool>::subscriptions(&config);
207    }
208}