Skip to main content

text_mirror/runner/
jail.rs

1//! The jail contract every subprocess adapter runs under.
2//!
3//! One trait presents the contract and platform backends implement
4//! it. No jail means no run: the runner only obtains child commands
5//! from a backend, every backend routes through the sandbox helper
6//! mode, and a platform without a backend refuses the adapter run
7//! with a machine reason. An unjailed run is not a degraded mode, it
8//! is impossible by construction.
9//!
10//! Before a backend accepts adapter work, a capability probe must
11//! prove the jail from the inside: an outbound IPv4 connection, an
12//! outbound IPv6 connection, a Unix socket connection to a listener
13//! the parent holds outside the jail, and a UDP send must each
14//! return an error before the probe deadline. A success or a hang
15//! proves nothing and fails the probe. Only a passed probe is cached, keyed by the policy digest,
16//! and the cache lives in this process, which cannot outlive the
17//! boot, so a cached pass never crosses a boot or a policy change.
18
19use std::collections::HashSet;
20use std::path::Path;
21use std::process::Command;
22use std::sync::{Mutex, OnceLock};
23
24use super::protocol::bodies::{ProbeName, ProbeNetRequest, ProbeOutcome, ProbeReport};
25use super::{ClosureFault, Limits, Runner, RunnerError, RunnerMessage};
26use crate::hash;
27
28/// Which measured jail profile a worker mode runs under.
29///
30/// The class is the authorization, not a convenience: the accelerator
31/// allowances were measured for one pinned engine driven by one
32/// worker mode, so only that mode may ask for them. A backend keys
33/// those allowances on this class alone and never on the presence of
34/// an executable grant, so a future granted worker inherits nothing
35/// unmeasured. The literal grant lines stay keyed on the grants
36/// themselves.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum RuntimeProfile {
39    /// The base profile every worker mode runs under.
40    Plain,
41    /// The base profile plus the device allowances measured for the
42    /// pinned accelerator engine. Only the audio worker mode.
43    Accelerator,
44    /// The provider jail: the widened profile measured for the pinned
45    /// external rasterizer, which spawns helper processes, reads a
46    /// larger runtime closure, and registers its own services. Only the
47    /// svg raster worker mode. It is a class of its own and shares
48    /// nothing with the accelerator class above: neither inherits the
49    /// other's allowances, and a platform with no measured provider
50    /// profile refuses this class outright.
51    Provider,
52}
53
54impl RuntimeProfile {
55    /// The stable name this class contributes to the policy digest.
56    pub(super) fn label(self) -> &'static str {
57        match self {
58            RuntimeProfile::Plain => "plain",
59            RuntimeProfile::Accelerator => "accelerator",
60            RuntimeProfile::Provider => "provider",
61        }
62    }
63}
64
65/// The provider jail's measured parameters: the executable closures the
66/// pinned components load from, the service namespace their runtime
67/// registers under, and the temp-directory variable it reads.
68///
69/// All three are deployment data, so none is a literal in this crate.
70/// The fields are private and the only constructor validates: the
71/// closures must not be shared roots, the namespace must fit the one
72/// closed grammar the profile accepts, and the variable must be a
73/// bounded identifier. A caller inside or outside the crate cannot
74/// hand the renderer a value the configuration layer would have
75/// refused, so the render boundary and the configuration boundary
76/// enforce the same shapes.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct ProviderJail {
79    closures: Vec<std::path::PathBuf>,
80    service_prefix: Option<String>,
81    temp_env: Option<String>,
82}
83
84impl ProviderJail {
85    /// Validates and builds the parameters. Every refusal is
86    /// `sandbox_unavailable`, because a jail that cannot be rendered
87    /// as measured is a jail that must not run.
88    pub fn new(
89        closures: Vec<std::path::PathBuf>,
90        service_prefix: Option<String>,
91        temp_env: Option<String>,
92    ) -> Result<ProviderJail, RunnerError> {
93        // Every entry is rendered as a grant on the tree it really
94        // names: resolved here, refused when it cannot be, so an alias
95        // through `..` or a link never reaches the profile and the
96        // shared-root refusal judges the real path.
97        let mut real_closures = Vec::with_capacity(closures.len());
98        // The messages name the entry by its position and the fault,
99        // never the path: a refusal here can reach a record, and no
100        // deployment path belongs in one.
101        let total = closures.len();
102        for (index, closure) in closures.iter().enumerate() {
103            let position = index + 1;
104            let refuse = |fault| {
105                RunnerError::new(
106                    "sandbox_unavailable",
107                    RunnerMessage::Closure {
108                        position,
109                        count: total,
110                        fault,
111                    },
112                )
113            };
114            if !closure.is_absolute() {
115                return Err(refuse(ClosureFault::NotAbsolute));
116            }
117            if !crate::convert::provider::is_normal_form(closure) {
118                return Err(refuse(ClosureFault::NotNormal));
119            }
120            if crate::convert::provider::is_symlink_entry(closure) {
121                return Err(refuse(ClosureFault::Symlink));
122            }
123            if crate::convert::provider::is_shared_root(closure) {
124                return Err(refuse(ClosureFault::SharedRoot));
125            }
126            let Some(real) = crate::convert::provider::canonical(closure) else {
127                return Err(refuse(ClosureFault::Unresolved));
128            };
129            if !crate::convert::provider::is_real_entry(closure) {
130                return Err(refuse(ClosureFault::NotFileOrDirectory));
131            }
132            if crate::convert::provider::is_shared_root(&real) {
133                return Err(refuse(ClosureFault::ResolvedSharedRoot));
134            }
135            real_closures.push(real);
136        }
137        if let Some(prefix) = &service_prefix
138            && !crate::convert::provider::is_service_namespace(prefix)
139        {
140            return Err(RunnerError::new(
141                "sandbox_unavailable",
142                RunnerMessage::ServiceNamespace,
143            ));
144        }
145        if let Some(name) = &temp_env
146            && !crate::convert::provider::is_env_name(name)
147        {
148            return Err(RunnerError::new(
149                "sandbox_unavailable",
150                RunnerMessage::TempDirectoryVariable,
151            ));
152        }
153        Ok(ProviderJail {
154            closures: real_closures,
155            service_prefix,
156            temp_env,
157        })
158    }
159
160    /// The enumerated closure entries, each granted on its own.
161    pub fn closures(&self) -> &[std::path::PathBuf] {
162        &self.closures
163    }
164
165    /// The service namespace, when the runtime registers any.
166    pub fn service_prefix(&self) -> Option<&str> {
167        self.service_prefix.as_deref()
168    }
169
170    /// The additional temp-directory variable, when the runtime reads
171    /// one. Not rendered into the profile, but part of this jail's
172    /// identity all the same.
173    pub fn temp_env(&self) -> Option<&str> {
174        self.temp_env.as_deref()
175    }
176}
177
178/// What one adapter invocation needs from a backend.
179pub struct SpawnSpec<'a> {
180    /// Absolute path of the worker binary.
181    pub worker: &'a Path,
182    /// Absolute path of the per-run jail directory.
183    pub jail: &'a Path,
184    /// The worker mode to execute after sandbox setup.
185    pub mode: &'a str,
186    /// Resource limits the helper applies before exec.
187    pub limits: &'a Limits,
188    /// Whether the syscall filter layer engages. Always true for
189    /// adapter work. The network probe disables it once, on Linux, to
190    /// observe namespace denial directly, and a second probe stage
191    /// proves the filter kills socket creation.
192    pub seccomp: bool,
193    /// Literal files the jail additionally grants read and execute
194    /// on: the pinned engine and probe binaries. Never a directory.
195    pub exec_grants: &'a [std::path::PathBuf],
196    /// Literal files the jail additionally grants read on: the pinned
197    /// weights and other read-only runtime files. Never a directory.
198    pub read_grants: &'a [std::path::PathBuf],
199    /// The measured jail profile this worker mode is authorized for.
200    pub runtime_profile: RuntimeProfile,
201    /// The provider jail's measured parameters, present only for the
202    /// provider class. The class is the authorization and these are the
203    /// necessity: without them the provider profile renders nothing
204    /// wider than the base, so no other worker can reach the widened
205    /// allowances by naming the class alone.
206    pub provider: Option<&'a ProviderJail>,
207}
208
209/// A platform jail backend.
210///
211/// Implementations must never return a command that runs the worker
212/// outside the platform jail.
213pub trait JailBackend: Send + Sync {
214    /// Stable backend name for probe failures and diagnostics.
215    fn name(&self) -> &'static str;
216
217    /// Builds the jailed command for one invocation. The runner still
218    /// applies the portable contract on top: cleared environment,
219    /// working directory inside the jail, piped stdio, and a fresh
220    /// process group.
221    fn command(&self, spec: &SpawnSpec<'_>) -> Result<Command, RunnerError>;
222
223    /// A stable description of the enforced policy. It feeds the
224    /// probe cache digest, so any policy change invalidates every
225    /// cached probe pass.
226    fn policy_material(&self) -> String;
227
228    /// Proves the jail from the inside before any adapter work.
229    fn probe(&self, runner: &Runner) -> Result<(), RunnerError>;
230}
231
232/// The backend for the current platform, or a refusal on a platform
233/// without one.
234pub fn platform_backend() -> Result<Box<dyn JailBackend>, RunnerError> {
235    #[cfg(target_os = "linux")]
236    {
237        Ok(Box::new(super::linux::NamespaceJail))
238    }
239    #[cfg(target_os = "macos")]
240    {
241        Ok(Box::new(super::macos::SeatbeltJail))
242    }
243    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
244    {
245        Err(RunnerError::new(
246            "sandbox_unavailable",
247            RunnerMessage::PlatformBackendMissing,
248        ))
249    }
250}
251
252fn probe_cache() -> &'static Mutex<HashSet<String>> {
253    static CACHE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
254    CACHE.get_or_init(|| Mutex::new(HashSet::new()))
255}
256
257/// The digest a cached probe pass is keyed by: the backend policy,
258/// the worker path, the worker binary hash, the literal grant paths,
259/// and the runtime profile class, so a grant or class change
260/// invalidates every cached probe pass and a probe result for one
261/// class never satisfies the other.
262pub(super) fn policy_digest(
263    backend: &dyn JailBackend,
264    worker: &Path,
265    exec_grants: &[std::path::PathBuf],
266    read_grants: &[std::path::PathBuf],
267    runtime_profile: RuntimeProfile,
268    provider: Option<&ProviderJail>,
269) -> Result<String, RunnerError> {
270    let worker_hash = hash::hash_file(worker).map_err(|e| {
271        let kind = match e {
272            crate::Error::Io { source, .. } => source.kind(),
273            _ => std::io::ErrorKind::Other,
274        };
275        RunnerError::new("worker_not_found", RunnerMessage::WorkerHash(kind))
276    })?;
277    let grant_lines = |label: &str, grants: &[std::path::PathBuf]| {
278        grants
279            .iter()
280            .map(|path| format!("{label}={}", path.display()))
281            .collect::<Vec<_>>()
282            .join("\n")
283    };
284    let provider_material = match provider {
285        Some(provider) => format!(
286            "closures={}\nservice-prefix={}\ntemp-env={}",
287            provider
288                .closures
289                .iter()
290                .map(|path| path.display().to_string())
291                .collect::<Vec<_>>()
292                .join(","),
293            provider.service_prefix().unwrap_or_default(),
294            provider.temp_env().unwrap_or_default()
295        ),
296        None => String::new(),
297    };
298    let material = format!(
299        "{}\n{}\n{}\n{}\n{}\n{}\nruntime-profile={}\n{}",
300        backend.name(),
301        backend.policy_material(),
302        worker.display(),
303        worker_hash,
304        grant_lines("exec", exec_grants),
305        grant_lines("read", read_grants),
306        runtime_profile.label(),
307        provider_material,
308    );
309    Ok(hash::hash_bytes(material.as_bytes()))
310}
311
312/// Runs the capability probe unless this policy digest already
313/// passed in this process. Only success is cached.
314pub(super) fn ensure_probed(runner: &Runner, digest: &str) -> Result<(), RunnerError> {
315    if probe_cache()
316        .lock()
317        .is_ok_and(|cache| cache.contains(digest))
318    {
319        return Ok(());
320    }
321    runner.backend().probe(runner)?;
322    if let Ok(mut cache) = probe_cache().lock() {
323        cache.insert(digest.to_string());
324    }
325    Ok(())
326}
327
328/// Whether a digest is already cached, for tests.
329pub fn probe_cached(digest: &str) -> bool {
330    probe_cache()
331        .lock()
332        .is_ok_and(|cache| cache.contains(digest))
333}
334
335/// Runs the `probe-net` worker mode inside the jail and requires
336/// every egress attempt to return an error before the probe deadline.
337///
338/// The parent holds a live Unix socket listener outside the jail for
339/// the duration. The Unix attempt would succeed from an unjailed
340/// process, so when it fails the jail provably blocked it.
341pub(super) fn probe_net(runner: &Runner, seccomp: bool) -> Result<(), RunnerError> {
342    let listener = UnixProbeListener::bind()?;
343    // Loopback TCP listeners the parent holds for the probe's
344    // duration. From an unjailed process both connects would succeed,
345    // so an immediate failure is evidence of the jail, and a timeout
346    // is scored as its own outcome and fails the probe.
347    let tcp4 = std::net::TcpListener::bind("127.0.0.1:0").map_err(probe_bind_error)?;
348    let tcp6 = std::net::TcpListener::bind("[::1]:0").map_err(probe_bind_error)?;
349    let tcp4_target = tcp4.local_addr().map_err(probe_bind_error)?.to_string();
350    let tcp6_target = tcp6.local_addr().map_err(probe_bind_error)?.to_string();
351    let payload = serde_json::to_value(ProbeNetRequest {
352        unix_target: listener.target.clone(),
353        tcp4_target,
354        tcp6_target,
355    })
356    .map_err(|_| RunnerError::new("sandbox_probe_failed", RunnerMessage::ProbeRequestEncode))?;
357    let response = runner.run_unprobed("probe-net", payload, &[], seccomp)?;
358    let Some(ok) = response.ok else {
359        return Err(RunnerError::new(
360            "sandbox_probe_failed",
361            RunnerMessage::ProbeReportedFailure,
362        ));
363    };
364    let report: ProbeReport = serde_json::from_value(ok)
365        .map_err(|_| RunnerError::new("sandbox_probe_failed", RunnerMessage::ProbeResponseParse))?;
366    let expected = [
367        ProbeName::Ipv4Tcp,
368        ProbeName::Ipv6Tcp,
369        ProbeName::UnixSocket,
370        ProbeName::UdpSend,
371    ];
372    for name in expected {
373        let Some(attempt) = report.attempts.iter().find(|a| a.name == name) else {
374            return Err(RunnerError::new(
375                "sandbox_probe_failed",
376                RunnerMessage::ProbeAttemptMissing(name),
377            ));
378        };
379        if attempt.outcome != ProbeOutcome::Denied {
380            return Err(RunnerError::new(
381                "sandbox_probe_failed",
382                RunnerMessage::ProbeAttempt {
383                    name,
384                    outcome: attempt.outcome,
385                    error_kind: attempt.error_kind,
386                },
387            ));
388        }
389    }
390    Ok(())
391}
392
393/// A Unix socket listener the parent holds open outside the jail
394/// while the probe runs.
395struct UnixProbeListener {
396    target: String,
397    // Held for their drop side effects: the listener keeps the socket
398    // connectable and the directory removes a path-based socket file.
399    _listener: std::os::unix::net::UnixListener,
400    _dir: Option<tempfile::TempDir>,
401}
402
403impl UnixProbeListener {
404    #[cfg(target_os = "linux")]
405    fn bind() -> Result<UnixProbeListener, RunnerError> {
406        use std::os::linux::net::SocketAddrExt;
407        use std::sync::atomic::{AtomicU64, Ordering};
408        // The abstract name must be unique across concurrent probes in
409        // one process, so a monotonic counter joins the process id.
410        static NEXT: AtomicU64 = AtomicU64::new(0);
411        let name = format!(
412            "text-mirror-probe-{}-{}",
413            std::process::id(),
414            NEXT.fetch_add(1, Ordering::Relaxed)
415        );
416        let address = std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes())
417            .map_err(probe_bind_error)?;
418        let listener =
419            std::os::unix::net::UnixListener::bind_addr(&address).map_err(probe_bind_error)?;
420        Ok(UnixProbeListener {
421            target: name,
422            _listener: listener,
423            _dir: None,
424        })
425    }
426
427    #[cfg(not(target_os = "linux"))]
428    fn bind() -> Result<UnixProbeListener, RunnerError> {
429        let dir = tempfile::tempdir().map_err(probe_bind_error)?;
430        let path = dir.path().join("probe.sock");
431        let listener = std::os::unix::net::UnixListener::bind(&path).map_err(probe_bind_error)?;
432        Ok(UnixProbeListener {
433            target: path.display().to_string(),
434            _listener: listener,
435            _dir: Some(dir),
436        })
437    }
438}
439
440fn probe_bind_error(e: std::io::Error) -> RunnerError {
441    RunnerError::new(
442        "sandbox_probe_failed",
443        RunnerMessage::ProbeListener(e.kind()),
444    )
445}