text_mirror/runner/
jail.rs1use std::collections::HashSet;
20use std::path::Path;
21use std::process::Command;
22use std::sync::{Mutex, OnceLock};
23
24use super::protocol::bodies::{ProbeNetRequest, ProbeReport};
25use super::{Limits, Runner, RunnerError};
26use crate::hash;
27
28pub struct SpawnSpec<'a> {
30 pub worker: &'a Path,
32 pub jail: &'a Path,
34 pub mode: &'a str,
36 pub limits: &'a Limits,
38 pub seccomp: bool,
43}
44
45pub trait JailBackend: Send + Sync {
50 fn name(&self) -> &'static str;
52
53 fn command(&self, spec: &SpawnSpec<'_>) -> Result<Command, RunnerError>;
58
59 fn policy_material(&self) -> String;
63
64 fn probe(&self, runner: &Runner) -> Result<(), RunnerError>;
66}
67
68pub fn platform_backend() -> Result<Box<dyn JailBackend>, RunnerError> {
71 #[cfg(target_os = "linux")]
72 {
73 Ok(Box::new(super::linux::NamespaceJail))
74 }
75 #[cfg(target_os = "macos")]
76 {
77 Ok(Box::new(super::macos::SeatbeltJail))
78 }
79 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
80 {
81 Err(RunnerError {
82 code: "sandbox_unavailable",
83 message: "no jail backend exists for this platform, refusing to run adapters"
84 .to_string(),
85 })
86 }
87}
88
89fn probe_cache() -> &'static Mutex<HashSet<String>> {
90 static CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
91 CACHE.get_or_init(|| Mutex::new(HashSet::new()))
92}
93
94pub(super) fn policy_digest(
97 backend: &dyn JailBackend,
98 worker: &Path,
99) -> Result<String, RunnerError> {
100 let worker_hash = hash::hash_file(worker).map_err(|e| RunnerError {
101 code: "worker_not_found",
102 message: format!("cannot hash worker binary {}: {e}", worker.display()),
103 })?;
104 let material = format!(
105 "{}\n{}\n{}\n{}",
106 backend.name(),
107 backend.policy_material(),
108 worker.display(),
109 worker_hash
110 );
111 Ok(hash::hash_bytes(material.as_bytes()))
112}
113
114pub(super) fn ensure_probed(runner: &Runner, digest: &str) -> Result<(), RunnerError> {
117 if probe_cache()
118 .lock()
119 .is_ok_and(|cache| cache.contains(digest))
120 {
121 return Ok(());
122 }
123 runner.backend().probe(runner)?;
124 if let Ok(mut cache) = probe_cache().lock() {
125 cache.insert(digest.to_string());
126 }
127 Ok(())
128}
129
130pub fn probe_cached(digest: &str) -> bool {
132 probe_cache()
133 .lock()
134 .is_ok_and(|cache| cache.contains(digest))
135}
136
137pub(super) fn probe_net(runner: &Runner, seccomp: bool) -> Result<(), RunnerError> {
144 let listener = UnixProbeListener::bind()?;
145 let tcp4 = std::net::TcpListener::bind("127.0.0.1:0").map_err(probe_bind_error)?;
150 let tcp6 = std::net::TcpListener::bind("[::1]:0").map_err(probe_bind_error)?;
151 let tcp4_target = tcp4.local_addr().map_err(probe_bind_error)?.to_string();
152 let tcp6_target = tcp6.local_addr().map_err(probe_bind_error)?.to_string();
153 let payload = serde_json::to_value(ProbeNetRequest {
154 unix_target: listener.target.clone(),
155 tcp4_target,
156 tcp6_target,
157 })
158 .map_err(|e| RunnerError {
159 code: "sandbox_probe_failed",
160 message: format!("cannot encode probe request: {e}"),
161 })?;
162 let response = runner.run_unprobed("probe-net", payload, &[], seccomp)?;
163 let Some(ok) = response.ok else {
164 let detail = response
165 .error
166 .map(|e| format!("{}: {}", e.code, e.message))
167 .unwrap_or_else(|| "empty response".to_string());
168 return Err(RunnerError {
169 code: "sandbox_probe_failed",
170 message: format!("network probe reported a failure: {detail}"),
171 });
172 };
173 let report: ProbeReport = serde_json::from_value(ok).map_err(|e| RunnerError {
174 code: "sandbox_probe_failed",
175 message: format!("network probe response did not parse: {e}"),
176 })?;
177 let expected = ["ipv4_tcp", "ipv6_tcp", "unix_socket", "udp_send"];
178 for name in expected {
179 let Some(attempt) = report.attempts.iter().find(|a| a.name == name) else {
180 return Err(RunnerError {
181 code: "sandbox_probe_failed",
182 message: format!("network probe skipped the {name} attempt"),
183 });
184 };
185 if attempt.outcome != "denied" {
186 return Err(RunnerError {
187 code: "sandbox_probe_failed",
188 message: format!(
189 "network probe attempt {name} ended {} ({}), the jail is not proven",
190 attempt.outcome, attempt.detail
191 ),
192 });
193 }
194 }
195 Ok(())
196}
197
198struct UnixProbeListener {
201 target: String,
202 _listener: std::os::unix::net::UnixListener,
205 _dir: Option<tempfile::TempDir>,
206}
207
208impl UnixProbeListener {
209 #[cfg(target_os = "linux")]
210 fn bind() -> Result<UnixProbeListener, RunnerError> {
211 use std::os::linux::net::SocketAddrExt;
212 use std::sync::atomic::{AtomicU64, Ordering};
213 static NEXT: AtomicU64 = AtomicU64::new(0);
216 let name = format!(
217 "text-mirror-probe-{}-{}",
218 std::process::id(),
219 NEXT.fetch_add(1, Ordering::Relaxed)
220 );
221 let address = std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())
222 .map_err(probe_bind_error)?;
223 let listener =
224 std::os::unix::net::UnixListener::bind_addr(&address).map_err(probe_bind_error)?;
225 Ok(UnixProbeListener {
226 target: name,
227 _listener: listener,
228 _dir: None,
229 })
230 }
231
232 #[cfg(not(target_os = "linux"))]
233 fn bind() -> Result<UnixProbeListener, RunnerError> {
234 let dir = tempfile::tempdir().map_err(probe_bind_error)?;
235 let path = dir.path().join("probe.sock");
236 let listener = std::os::unix::net::UnixListener::bind(&path).map_err(probe_bind_error)?;
237 Ok(UnixProbeListener {
238 target: path.display().to_string(),
239 _listener: listener,
240 _dir: Some(dir),
241 })
242 }
243}
244
245fn probe_bind_error(e: std::io::Error) -> RunnerError {
246 RunnerError {
247 code: "sandbox_probe_failed",
248 message: format!("cannot stand up the Unix probe listener: {e}"),
249 }
250}