Skip to main content

text_mirror/runner/
mod.rs

1//! The sandboxed subprocess runner behind every subprocess adapter.
2//!
3//! The runner enforces the whole sandbox contract from the design
4//! document: a wall-clock timeout, separate stdout and stderr caps
5//! drained concurrently, no network, no inherited environment, and a
6//! temp-dir working jail. Every violation fails closed with a
7//! machine-readable reason and no partial result.
8//!
9//! The spawn is two-stage. The parent builds a `std::process::Command`
10//! through the platform [`jail::JailBackend`] with a cleared
11//! environment, the jail as working directory, piped stdio, and a
12//! fresh process group. The child starts in the worker binary's
13//! single-threaded sandbox helper mode, which applies namespaces,
14//! resource limits, descriptor hygiene, the filesystem jail, and the
15//! syscall filter, then execs the requested adapter mode. No sandbox
16//! work runs in a `pre_exec` closure.
17//!
18//! The wall clock is a parent-owned monotonic deadline. When it fires,
19//! or when either output cap is crossed, the parent kills the whole
20//! process group with SIGKILL. The same group kill also runs after a
21//! normal clean exit, so no descendant outlives the invocation, and
22//! the post-exit drain join is deadline-bounded, so the parent never
23//! blocks on a pipe an escaped descendant holds open. A response
24//! frame is accepted only from a child that wrote exactly one
25//! complete frame, nothing after it, and exited cleanly.
26
27use std::io::Read;
28use std::path::PathBuf;
29use std::process::{Child, Stdio};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::time::{Duration, Instant};
33
34pub mod helper;
35pub mod jail;
36mod memory;
37pub mod protocol;
38pub mod worker;
39
40#[cfg(target_os = "linux")]
41pub mod linux;
42#[cfg(target_os = "macos")]
43pub mod macos;
44
45use jail::{JailBackend, ProviderJail, RuntimeProfile, SpawnSpec};
46use protocol::bodies::{ProbeName, ProbeOutcome};
47use protocol::{FrameRead, Request, RequestSchema, Response, ResponseValidationError};
48
49/// Resource and output limits for one adapter class.
50#[derive(Debug, Clone)]
51pub struct Limits {
52    /// Wall-clock ceiling enforced by the parent.
53    pub wall_timeout: Duration,
54    /// Ceiling on the response frame payload, checked against the
55    /// declared length before allocation.
56    pub max_response_bytes: u32,
57    /// Ceiling on stderr bytes, drained and counted concurrently.
58    pub max_stderr_bytes: u64,
59    /// RLIMIT_AS for the child, in bytes. `None` leaves the limit
60    /// unset, the shape a profile takes on a platform where the mapped
61    /// accelerator runtime reserves a virtual range no sane cap sits
62    /// above; the resident-memory guard below is the bound there.
63    pub address_space_bytes: Option<u64>,
64    /// RLIMIT_CPU for the child, in seconds.
65    pub cpu_seconds: u64,
66    /// RLIMIT_FSIZE for the child, in bytes. Caps regular-file growth
67    /// inside the jail. Stdout is a pipe, so the response cap above
68    /// is enforced by the parent instead.
69    pub file_size_bytes: u64,
70    /// RLIMIT_NPROC for the child: this invocation's spawn budget, so
71    /// a compromised adapter cannot fork-bomb the host. The limit
72    /// counts every process of the mapped real user id, not just this
73    /// invocation's descendants, so the helper applies the number as
74    /// headroom above the count that already exists at spawn, clamped
75    /// to the hard limit. The semantic is defined there once and
76    /// holds for every profile. A count the host will not answer
77    /// refuses the run with `process-count-unavailable`, never a
78    /// substituted baseline.
79    pub max_processes: u64,
80    /// Parent-side ceiling on the aggregate resident bytes of the
81    /// child's whole process group, engine descendants included. The
82    /// parent polls the group while the child runs, kills the group
83    /// when the sum crosses the ceiling, and fails closed with
84    /// `worker-memory-exceeded`. A failed measurement is itself a
85    /// failure, `memory-monitor-failed`, never a silent pass. `None`
86    /// disables the monitor, the shape of every profile whose worker
87    /// spawns no engine child.
88    pub max_resident_bytes: Option<u64>,
89}
90
91impl Default for Limits {
92    fn default() -> Limits {
93        Limits {
94            wall_timeout: Duration::from_secs(120),
95            max_response_bytes: 192 * 1024 * 1024,
96            max_stderr_bytes: 1024 * 1024,
97            address_space_bytes: Some(4 * 1024 * 1024 * 1024),
98            cpu_seconds: 300,
99            file_size_bytes: 512 * 1024 * 1024,
100            max_processes: 16,
101            max_resident_bytes: None,
102        }
103    }
104}
105
106/// A runner failure with a machine-readable reason.
107///
108/// The codes are stable: `sandbox_unavailable`, `sandbox_probe_failed`,
109/// `worker_not_found`, `adapter_spawn_error`, `adapter_timeout`,
110/// `adapter_frame_oversized`, `adapter_output_overflow`,
111/// `adapter_protocol_error`, `adapter_drain_timeout`,
112/// `adapter_crash`, `worker-memory-exceeded`,
113/// `memory-monitor-failed`, and `process-count-unavailable`.
114#[derive(Debug, Clone)]
115pub struct RunnerError {
116    /// Stable reason code.
117    pub code: &'static str,
118    message: RunnerMessage,
119}
120
121impl RunnerError {
122    fn new(code: &'static str, message: RunnerMessage) -> RunnerError {
123        RunnerError { code, message }
124    }
125
126    /// Path-free detail for a human reading the manifest.
127    pub fn message(&self) -> String {
128        self.message.to_string()
129    }
130}
131
132impl std::fmt::Display for RunnerError {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        write!(f, "{}: {}", self.code, self.message)
135    }
136}
137
138impl std::error::Error for RunnerError {}
139
140#[derive(Debug, Clone, Copy)]
141enum GrantKind {
142    Executable,
143    Read,
144}
145
146#[derive(Debug, Clone, Copy)]
147enum GrantFault {
148    Symlink,
149    NotRegular,
150    Io(std::io::ErrorKind),
151}
152
153#[derive(Debug, Clone, Copy)]
154enum ClosureFault {
155    NotAbsolute,
156    NotNormal,
157    Symlink,
158    SharedRoot,
159    Unresolved,
160    NotFileOrDirectory,
161    ResolvedSharedRoot,
162}
163
164#[derive(Debug, Clone, Copy)]
165enum PathClass {
166    Jail,
167    Worker,
168    ExecutableGrantDirectory,
169    ExecutableGrant,
170    ReadGrant,
171}
172
173#[derive(Debug, Clone, Copy)]
174enum TemplateValue {
175    Jail,
176    Worker,
177    Closures,
178    Service,
179    Grants,
180}
181
182#[derive(Debug, Clone, Copy)]
183enum ExitDetail {
184    SandboxSetup(i32),
185    ProcessCount(i32),
186    WorkerCode(i32),
187    WorkerSignal(i32),
188    WorkerNoStatus,
189}
190
191#[allow(dead_code)]
192#[derive(Debug, Clone, Copy)]
193enum RunnerMessage {
194    CurrentExecutable(std::io::ErrorKind),
195    CurrentExecutableHasNoDirectory,
196    WorkerBinaryResolve(std::io::ErrorKind),
197    WorkerBinaryMissing,
198    Grant {
199        kind: GrantKind,
200        position: usize,
201        count: usize,
202        fault: GrantFault,
203    },
204    JailDirectoryCreate(std::io::ErrorKind),
205    JailDirectoryCanonicalize(std::io::ErrorKind),
206    InputNameNotBare,
207    StageInput(std::io::ErrorKind),
208    SpawnWorker(std::io::ErrorKind),
209    DrainTimeout(u64),
210    WallTimeout(u64),
211    ResponseFrameOversized {
212        declared: u64,
213        ceiling: u64,
214    },
215    StderrOverflow {
216        written: u64,
217        ceiling: u64,
218    },
219    TrailingStdout,
220    Exit(ExitDetail),
221    EmptyResponse,
222    TruncatedResponse,
223    ReadResponse(std::io::ErrorKind),
224    ParseResponse,
225    ValidateResponse(ResponseValidationError),
226    MemoryExceeded {
227        total: u64,
228        ceiling: u64,
229    },
230    Memory(memory::MemoryError),
231    Closure {
232        position: usize,
233        count: usize,
234        fault: ClosureFault,
235    },
236    ServiceNamespace,
237    TempDirectoryVariable,
238    PlatformBackendMissing,
239    WorkerHash(std::io::ErrorKind),
240    ProbeRequestEncode,
241    ProbeReportedFailure,
242    ProbeResponseParse,
243    ProbeAttemptMissing(ProbeName),
244    ProbeAttempt {
245        name: ProbeName,
246        outcome: ProbeOutcome,
247        error_kind: std::io::ErrorKind,
248    },
249    ProbeListener(std::io::ErrorKind),
250    TemplateBrace(TemplateValue),
251    ClosureNotUtf8 {
252        position: usize,
253        count: usize,
254    },
255    ClosureCannotBeQuoted {
256        position: usize,
257        count: usize,
258    },
259    PathNotUtf8(PathClass),
260    PathCannotBeQuoted(PathClass),
261    ProviderNeedsWiring,
262    SandboxHelperMissing,
263    ProviderPlatformUnsupported,
264    SyscallProbeAllowed,
265    SyscallProbeFailed,
266}
267
268impl std::fmt::Display for RunnerMessage {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match *self {
271            RunnerMessage::CurrentExecutable(kind) => {
272                write!(f, "cannot resolve the current executable: {kind:?}")
273            }
274            RunnerMessage::CurrentExecutableHasNoDirectory => {
275                f.write_str("the current executable has no directory")
276            }
277            RunnerMessage::WorkerBinaryResolve(kind) => {
278                write!(f, "cannot resolve the worker binary: {kind:?}")
279            }
280            RunnerMessage::WorkerBinaryMissing => {
281                write!(f, "no {WORKER_BINARY} beside the current executable")
282            }
283            RunnerMessage::Grant {
284                kind,
285                position,
286                count,
287                fault,
288            } => {
289                let kind = match kind {
290                    GrantKind::Executable => "executable grant",
291                    GrantKind::Read => "read grant",
292                };
293                write!(
294                    f,
295                    "{kind} {position} of {count} is not a literal regular file: "
296                )?;
297                match fault {
298                    GrantFault::Symlink => f.write_str("it is a symlink"),
299                    GrantFault::NotRegular => f.write_str("it is not a regular file"),
300                    GrantFault::Io(kind) => write!(f, "{kind:?}"),
301                }
302            }
303            RunnerMessage::JailDirectoryCreate(kind) => {
304                write!(f, "cannot create the jail directory: {kind:?}")
305            }
306            RunnerMessage::JailDirectoryCanonicalize(kind) => {
307                write!(f, "cannot canonicalize the jail directory: {kind:?}")
308            }
309            RunnerMessage::InputNameNotBare => f.write_str("an input name is not a bare file name"),
310            RunnerMessage::StageInput(kind) => write!(f, "cannot stage an input: {kind:?}"),
311            RunnerMessage::SpawnWorker(kind) => {
312                write!(f, "cannot spawn the jailed worker: {kind:?}")
313            }
314            RunnerMessage::DrainTimeout(milliseconds) => write!(
315                f,
316                "a pipe stayed open {milliseconds} milliseconds after the group kill, so a descendant escaped the process group. The drain is detached and all output is discarded"
317            ),
318            RunnerMessage::WallTimeout(milliseconds) => write!(
319                f,
320                "killed the process group after the {milliseconds} millisecond wall-clock ceiling, partial output discarded"
321            ),
322            RunnerMessage::ResponseFrameOversized { declared, ceiling } => write!(
323                f,
324                "response frame declared {declared} bytes over the {ceiling} byte ceiling, refused before allocation"
325            ),
326            RunnerMessage::StderrOverflow { written, ceiling } => {
327                write!(
328                    f,
329                    "stderr wrote {written} bytes over the {ceiling} byte cap"
330                )
331            }
332            RunnerMessage::TrailingStdout => {
333                f.write_str("bytes after the response frame, one frame is the whole stdout budget")
334            }
335            RunnerMessage::Exit(detail) => match detail {
336                ExitDetail::SandboxSetup(code) => write!(
337                    f,
338                    "the sandbox helper refused to engage the jail (exit {code})"
339                ),
340                ExitDetail::ProcessCount(code) => write!(
341                    f,
342                    "the sandbox helper could not measure the user's process count and refused the spawn (exit {code})"
343                ),
344                ExitDetail::WorkerCode(code) => write!(
345                    f,
346                    "worker exited with code {code}, any response frame is discarded"
347                ),
348                ExitDetail::WorkerSignal(signal) => write!(
349                    f,
350                    "worker died on signal {signal}, any response frame is discarded"
351                ),
352                ExitDetail::WorkerNoStatus => {
353                    f.write_str("worker exited without a status, any response frame is discarded")
354                }
355            },
356            RunnerMessage::EmptyResponse => f.write_str("worker exited without a response frame"),
357            RunnerMessage::TruncatedResponse => {
358                f.write_str("worker exited with a truncated response frame")
359            }
360            RunnerMessage::ReadResponse(kind) => {
361                write!(f, "reading the response failed: {kind:?}")
362            }
363            RunnerMessage::ParseResponse => f.write_str("response frame did not parse"),
364            RunnerMessage::ValidateResponse(detail) => match detail {
365                ResponseValidationError::BothBodies => {
366                    f.write_str("response carries both ok and error")
367                }
368                ResponseValidationError::NoBody => {
369                    f.write_str("response carries neither ok nor error")
370                }
371            },
372            RunnerMessage::MemoryExceeded { total, ceiling } => write!(
373                f,
374                "the worker process group holds {total} resident bytes over the {ceiling} byte ceiling, killed"
375            ),
376            RunnerMessage::Memory(error) => match error {
377                memory::MemoryError::ProcessTable(kind) => {
378                    write!(f, "cannot enumerate the process table: {kind:?}")
379                }
380                memory::MemoryError::ProcessGroup(kind) => {
381                    write!(f, "cannot enumerate the process group: {kind:?}")
382                }
383                memory::MemoryError::ProcessGroupRecheck(kind) => {
384                    write!(f, "cannot re-enumerate the process group: {kind:?}")
385                }
386                memory::MemoryError::GroupLeaderPid(pid) => {
387                    write!(f, "group leader pid {pid} does not fit a pid_t")
388                }
389                memory::MemoryError::GroupMemberPid(pid) => {
390                    write!(f, "group member pid {pid} does not fit a pid_t")
391                }
392                memory::MemoryError::ResidentSizeSum => {
393                    f.write_str("the resident-size sum overflowed")
394                }
395                memory::MemoryError::ResidentSizeRead(pid) => {
396                    write!(f, "cannot read the resident size of process {pid}")
397                }
398                memory::MemoryError::StatRecord(pid) => {
399                    write!(f, "cannot parse the stat record of process {pid}")
400                }
401                memory::MemoryError::ProcessRecords(pid, kind) => {
402                    write!(f, "cannot read the records of process {pid}: {kind:?}")
403                }
404                memory::MemoryError::ProcessRecheck(pid, kind) => write!(
405                    f,
406                    "cannot re-check process {pid} after a read failure: {kind:?}"
407                ),
408                memory::MemoryError::ResidentSizeValue => f.write_str("cannot parse a VmRSS value"),
409                memory::MemoryError::ResidentSizeUnit => {
410                    f.write_str("a VmRSS line is not in kibibytes")
411                }
412                memory::MemoryError::ResidentSizeScale => f.write_str("a VmRSS value overflowed"),
413                memory::MemoryError::MonitorUnavailable => {
414                    f.write_str("no resident-memory monitor exists for this platform")
415                }
416            },
417            RunnerMessage::Closure {
418                position,
419                count,
420                fault,
421            } => {
422                write!(f, "closure entry {position} of {count} ")?;
423                match fault {
424                    ClosureFault::NotAbsolute => f.write_str("is not absolute"),
425                    ClosureFault::NotNormal => f.write_str("is not in normal form"),
426                    ClosureFault::Symlink => f.write_str("is a symlink, not the tree it names"),
427                    ClosureFault::SharedRoot => {
428                        f.write_str("is a shared root, not a measured dependency")
429                    }
430                    ClosureFault::Unresolved => f.write_str("cannot be resolved"),
431                    ClosureFault::NotFileOrDirectory => {
432                        f.write_str("is not a directory or a regular file")
433                    }
434                    ClosureFault::ResolvedSharedRoot => {
435                        f.write_str("resolves to a shared root, not a measured dependency")
436                    }
437                }
438            }
439            RunnerMessage::ServiceNamespace => {
440                f.write_str("the service namespace is not an anchored dotted prefix")
441            }
442            RunnerMessage::TempDirectoryVariable => {
443                f.write_str("the temp-directory variable is not a bounded identifier")
444            }
445            RunnerMessage::PlatformBackendMissing => {
446                f.write_str("no jail backend exists for this platform, refusing to run adapters")
447            }
448            RunnerMessage::WorkerHash(kind) => {
449                write!(f, "the worker binary cannot be hashed: {kind:?}")
450            }
451            RunnerMessage::ProbeRequestEncode => f.write_str("cannot encode probe request"),
452            RunnerMessage::ProbeReportedFailure => f.write_str("network probe reported a failure"),
453            RunnerMessage::ProbeResponseParse => {
454                f.write_str("network probe response did not parse")
455            }
456            RunnerMessage::ProbeAttemptMissing(name) => {
457                write!(f, "network probe skipped the {} attempt", probe_name(name))
458            }
459            RunnerMessage::ProbeAttempt {
460                name,
461                outcome,
462                error_kind,
463            } => write!(
464                f,
465                "network probe attempt {} ended {} ({error_kind:?}), the jail is not proven",
466                probe_name(name),
467                probe_outcome(outcome)
468            ),
469            RunnerMessage::ProbeListener(kind) => {
470                write!(f, "cannot stand up the Unix probe listener: {kind:?}")
471            }
472            RunnerMessage::TemplateBrace(value) => write!(
473                f,
474                "the {} value carries a brace and cannot be rendered",
475                template_value(value)
476            ),
477            RunnerMessage::ClosureNotUtf8 { position, count } => {
478                write!(f, "closure entry {position} of {count} is not UTF-8")
479            }
480            RunnerMessage::ClosureCannotBeQuoted { position, count } => write!(
481                f,
482                "closure entry {position} of {count} cannot be quoted in a jail profile"
483            ),
484            RunnerMessage::PathNotUtf8(class) => {
485                write!(f, "{} is not UTF-8", path_class(class))
486            }
487            RunnerMessage::PathCannotBeQuoted(class) => write!(
488                f,
489                "{} cannot be quoted in a Seatbelt profile",
490                path_class(class)
491            ),
492            RunnerMessage::ProviderNeedsWiring => {
493                f.write_str("the provider jail class needs a wired provider, refusing the run")
494            }
495            RunnerMessage::SandboxHelperMissing => {
496                f.write_str("the sandbox helper is missing, refusing to run adapters")
497            }
498            RunnerMessage::ProviderPlatformUnsupported => f.write_str(
499                "no provider jail profile is measured for this platform, refusing the run",
500            ),
501            RunnerMessage::SyscallProbeAllowed => {
502                f.write_str("the syscall filter let a socket creation through")
503            }
504            RunnerMessage::SyscallProbeFailed => {
505                f.write_str("the syscall filter probe failed to run")
506            }
507        }
508    }
509}
510
511fn probe_name(name: ProbeName) -> &'static str {
512    match name {
513        ProbeName::Ipv4Tcp => "ipv4_tcp",
514        ProbeName::Ipv6Tcp => "ipv6_tcp",
515        ProbeName::UnixSocket => "unix_socket",
516        ProbeName::UdpSend => "udp_send",
517    }
518}
519
520fn probe_outcome(outcome: ProbeOutcome) -> &'static str {
521    match outcome {
522        ProbeOutcome::Connected => "connected",
523        ProbeOutcome::Denied => "denied",
524        ProbeOutcome::Timeout => "timeout",
525        ProbeOutcome::InvalidTarget => "invalid_target",
526    }
527}
528
529fn path_class(class: PathClass) -> &'static str {
530    match class {
531        PathClass::Jail => "the jail path",
532        PathClass::Worker => "the worker path",
533        PathClass::ExecutableGrantDirectory => "an executable grant's directory",
534        PathClass::ExecutableGrant => "an executable grant",
535        PathClass::ReadGrant => "a read grant",
536    }
537}
538
539fn template_value(value: TemplateValue) -> &'static str {
540    match value {
541        TemplateValue::Jail => "jail template",
542        TemplateValue::Worker => "worker template",
543        TemplateValue::Closures => "closures template",
544        TemplateValue::Service => "service template",
545        TemplateValue::Grants => "grants template",
546    }
547}
548
549/// The BLAKE3 of the provider jail profile template on this platform,
550/// or `None` where no provider profile is measured. Part of the
551/// effective rules version whenever a provider is configured.
552pub fn provider_profile_template_blake3() -> Option<String> {
553    #[cfg(target_os = "macos")]
554    {
555        Some(macos::provider_profile_template_blake3())
556    }
557    #[cfg(not(target_os = "macos"))]
558    {
559        None
560    }
561}
562
563/// File name of the worker binary this crate ships.
564pub const WORKER_BINARY: &str = "text-mirror-worker";
565
566/// Exit code the sandbox helper uses when jail setup fails, so the
567/// parent can tell a refused jail from an adapter crash.
568pub const SANDBOX_SETUP_EXIT: i32 = 71;
569
570/// Exit code the sandbox helper uses when the real user id's process
571/// count cannot be measured, so the parent can name that refusal
572/// rather than folding it into every other setup failure.
573pub const PROCESS_COUNT_EXIT: i32 = 72;
574
575/// Locates the worker binary beside the current executable, or one
576/// directory up, which covers an installed layout and a build tree.
577pub fn locate_worker() -> Result<PathBuf, RunnerError> {
578    let current = std::env::current_exe().map_err(|e| {
579        RunnerError::new(
580            "worker_not_found",
581            RunnerMessage::CurrentExecutable(e.kind()),
582        )
583    })?;
584    let Some(dir) = current.parent() else {
585        return Err(RunnerError::new(
586            "worker_not_found",
587            RunnerMessage::CurrentExecutableHasNoDirectory,
588        ));
589    };
590    let mut candidates = vec![dir.join(WORKER_BINARY)];
591    if let Some(parent) = dir.parent() {
592        candidates.push(parent.join(WORKER_BINARY));
593    }
594    for candidate in &candidates {
595        if candidate.is_file() {
596            return candidate.canonicalize().map_err(|e| {
597                RunnerError::new(
598                    "worker_not_found",
599                    RunnerMessage::WorkerBinaryResolve(e.kind()),
600                )
601            });
602        }
603    }
604    Err(RunnerError::new(
605        "worker_not_found",
606        RunnerMessage::WorkerBinaryMissing,
607    ))
608}
609
610/// Runs worker modes under the platform jail.
611pub struct Runner {
612    backend: Box<dyn JailBackend>,
613    worker: PathBuf,
614    limits: Limits,
615    exec_grants: Vec<PathBuf>,
616    read_grants: Vec<PathBuf>,
617    runtime_profile: RuntimeProfile,
618    provider: Option<ProviderJail>,
619}
620
621impl Runner {
622    /// A runner over an explicit backend, worker, and limits, with no
623    /// grants beyond the worker and the jail.
624    pub fn new(backend: Box<dyn JailBackend>, worker: PathBuf, limits: Limits) -> Runner {
625        Runner::with_grants(
626            backend,
627            worker,
628            limits,
629            Vec::new(),
630            Vec::new(),
631            RuntimeProfile::Plain,
632        )
633    }
634
635    /// A runner whose jail additionally grants literal files: read and
636    /// execute for `exec_grants`, read only for `read_grants`. This is
637    /// how the pinned engine binaries and weights reach a jailed
638    /// worker: literal files only, never a directory, and the worker
639    /// still re-hashes each one against its pinned BLAKE3 before use.
640    ///
641    /// `runtime_profile` is the measured jail class this worker mode
642    /// is authorized for. Only the audio worker mode passes
643    /// [`RuntimeProfile::Accelerator`]; every other mode passes
644    /// [`RuntimeProfile::Plain`] whatever grants it carries, so no
645    /// worker inherits allowances measured for a different engine.
646    pub fn with_grants(
647        backend: Box<dyn JailBackend>,
648        worker: PathBuf,
649        limits: Limits,
650        exec_grants: Vec<PathBuf>,
651        read_grants: Vec<PathBuf>,
652        runtime_profile: RuntimeProfile,
653    ) -> Runner {
654        Runner {
655            backend,
656            worker,
657            limits,
658            exec_grants,
659            read_grants,
660            runtime_profile,
661            provider: None,
662        }
663    }
664
665    /// A runner under the provider jail class, whose profile
666    /// additionally grants the measured closures and service namespace
667    /// the pinned external components need.
668    ///
669    /// The class alone buys nothing: the backend renders the widened
670    /// profile only when this class arrives together with a non-empty
671    /// executable grant and these parameters, so a worker that names
672    /// the class with nothing wired keeps the base profile.
673    pub fn with_provider_jail(
674        backend: Box<dyn JailBackend>,
675        worker: PathBuf,
676        limits: Limits,
677        exec_grants: Vec<PathBuf>,
678        read_grants: Vec<PathBuf>,
679        provider: ProviderJail,
680    ) -> Runner {
681        Runner {
682            backend,
683            worker,
684            limits,
685            exec_grants,
686            read_grants,
687            runtime_profile: RuntimeProfile::Provider,
688            provider: Some(provider),
689        }
690    }
691
692    /// The platform backend, the co-located worker binary, and
693    /// default limits.
694    pub fn with_platform_defaults() -> Result<Runner, RunnerError> {
695        Ok(Runner::new(
696            jail::platform_backend()?,
697            locate_worker()?,
698            Limits::default(),
699        ))
700    }
701
702    /// The backend this runner spawns through.
703    pub fn backend(&self) -> &dyn JailBackend {
704        self.backend.as_ref()
705    }
706
707    /// The limits this runner enforces.
708    pub fn limits(&self) -> &Limits {
709        &self.limits
710    }
711
712    /// Test-only view of the literal grant lists, so a converter test
713    /// can prove exactly which capabilities its jail would receive.
714    #[cfg(test)]
715    pub(crate) fn grant_lists(&self) -> (&[PathBuf], &[PathBuf]) {
716        (&self.exec_grants, &self.read_grants)
717    }
718
719    /// Test-only view of the jail class, so a converter test can
720    /// prove which adapter modes ask for the device allowances.
721    #[cfg(test)]
722    pub(crate) fn runtime_profile(&self) -> RuntimeProfile {
723        self.runtime_profile
724    }
725
726    /// Test-only view of the provider jail parameters, so a converter
727    /// test can prove exactly which closures its jail would grant.
728    #[cfg(test)]
729    pub(crate) fn provider_jail(&self) -> Option<&ProviderJail> {
730        self.provider.as_ref()
731    }
732
733    /// The policy digest that keys this runner's probe cache entry.
734    pub fn policy_digest(&self) -> Result<String, RunnerError> {
735        jail::policy_digest(
736            self.backend.as_ref(),
737            &self.worker,
738            &self.exec_grants,
739            &self.read_grants,
740            self.runtime_profile,
741            self.provider.as_ref(),
742        )
743    }
744
745    /// Runs one adapter mode inside the jail and returns its response.
746    ///
747    /// `files` are written into a fresh jail directory before the
748    /// spawn, so the child reads inputs by bare name from its working
749    /// directory. The capability probe must have passed for the
750    /// current policy digest, and runs once per process if not.
751    pub fn run(
752        &self,
753        mode: &str,
754        payload: serde_json::Value,
755        files: &[(&str, &[u8])],
756    ) -> Result<Response, RunnerError> {
757        let digest = self.policy_digest()?;
758        jail::ensure_probed(self, &digest)?;
759        self.run_unprobed(mode, payload, files, true)
760    }
761
762    /// Runs one mode without the probe gate. Only the probe itself
763    /// uses this, and only through the jail backend.
764    pub(crate) fn run_unprobed(
765        &self,
766        mode: &str,
767        payload: serde_json::Value,
768        files: &[(&str, &[u8])],
769        seccomp: bool,
770    ) -> Result<Response, RunnerError> {
771        // Literal-file re-assertion at spawn time: every grant must be
772        // a regular file right now, whatever an earlier validation
773        // saw, so a path swapped for a directory or a symlink between
774        // configuration and spawn refuses the run instead of widening
775        // the jail.
776        // The refusal names the grant by its kind and position and the
777        // fault, never the path: this message reaches the record of
778        // whatever was being converted, and a deployment path does not
779        // belong there.
780        let grants =
781            self.exec_grants
782                .iter()
783                .enumerate()
784                .map(|(index, grant)| {
785                    (
786                        GrantKind::Executable,
787                        index + 1,
788                        self.exec_grants.len(),
789                        grant,
790                    )
791                })
792                .chain(self.read_grants.iter().enumerate().map(|(index, grant)| {
793                    (GrantKind::Read, index + 1, self.read_grants.len(), grant)
794                }));
795        for (kind, position, count, grant) in grants {
796            match std::fs::symlink_metadata(grant) {
797                Ok(metadata) if metadata.file_type().is_file() => {}
798                Ok(metadata) if metadata.file_type().is_symlink() => {
799                    return Err(RunnerError::new(
800                        "adapter_spawn_error",
801                        RunnerMessage::Grant {
802                            kind,
803                            position,
804                            count,
805                            fault: GrantFault::Symlink,
806                        },
807                    ));
808                }
809                Ok(_) => {
810                    return Err(RunnerError::new(
811                        "adapter_spawn_error",
812                        RunnerMessage::Grant {
813                            kind,
814                            position,
815                            count,
816                            fault: GrantFault::NotRegular,
817                        },
818                    ));
819                }
820                Err(e) => {
821                    return Err(RunnerError::new(
822                        "adapter_spawn_error",
823                        RunnerMessage::Grant {
824                            kind,
825                            position,
826                            count,
827                            fault: GrantFault::Io(e.kind()),
828                        },
829                    ));
830                }
831            }
832        }
833        let jail_dir = tempfile::tempdir().map_err(|e| {
834            RunnerError::new(
835                "adapter_spawn_error",
836                RunnerMessage::JailDirectoryCreate(e.kind()),
837            )
838        })?;
839        let jail_path = jail_dir.path().canonicalize().map_err(|e| {
840            RunnerError::new(
841                "adapter_spawn_error",
842                RunnerMessage::JailDirectoryCanonicalize(e.kind()),
843            )
844        })?;
845        for (name, bytes) in files {
846            if name.contains('/') || name.contains("..") {
847                return Err(RunnerError::new(
848                    "adapter_spawn_error",
849                    RunnerMessage::InputNameNotBare,
850                ));
851            }
852            std::fs::write(jail_path.join(name), bytes).map_err(|e| {
853                RunnerError::new("adapter_spawn_error", RunnerMessage::StageInput(e.kind()))
854            })?;
855        }
856
857        let spec = SpawnSpec {
858            worker: &self.worker,
859            jail: &jail_path,
860            mode,
861            limits: &self.limits,
862            seccomp,
863            exec_grants: &self.exec_grants,
864            read_grants: &self.read_grants,
865            runtime_profile: self.runtime_profile,
866            provider: self.provider.as_ref(),
867        };
868        let mut command = self.backend.command(&spec)?;
869        command
870            .env_clear()
871            .current_dir(&jail_path)
872            .stdin(Stdio::piped())
873            .stdout(Stdio::piped())
874            .stderr(Stdio::piped());
875        #[cfg(unix)]
876        {
877            use std::os::unix::process::CommandExt;
878            command.process_group(0);
879        }
880        let mut child = command.spawn().map_err(|e| {
881            RunnerError::new("adapter_spawn_error", RunnerMessage::SpawnWorker(e.kind()))
882        })?;
883
884        let request = Request {
885            schema: RequestSchema,
886            adapter: mode.to_string(),
887            payload,
888        };
889        if let Some(mut stdin) = child.stdin.take() {
890            // A child that dies before reading breaks the pipe. That
891            // is not a verdict, the exit status below is.
892            let _ = protocol::write_frame(&mut stdin, &request);
893        }
894
895        drive(&mut child, &self.limits)
896    }
897}
898
899/// What the stdout drain thread observed.
900enum StdoutEnd {
901    Frame(Vec<u8>),
902    Empty,
903    Truncated,
904    Oversized { declared: u64 },
905    Trailing,
906    ReadError(std::io::ErrorKind),
907}
908
909/// How long the parent waits for the drains to reach EOF after the
910/// final group kill. A drain still open past this bound means a
911/// descendant escaped the process group and holds a pipe, and the
912/// parent detaches the drain and fails closed instead of hanging.
913const DRAIN_JOIN_BOUND: Duration = Duration::from_secs(2);
914
915/// Drains both pipes concurrently, enforces the deadline and caps,
916/// and interprets the exit.
917///
918/// The group-lifetime contract: no descendant outlives the
919/// invocation. The parent SIGKILLs the whole process group on every
920/// exit path, the normal clean exit included, before it returns. The
921/// parent itself never blocks without a bound: the post-exit drain
922/// join is deadline-bounded, and a drain that does not finish is
923/// detached while the call returns a fail-closed reason.
924fn drive(child: &mut Child, limits: &Limits) -> Result<Response, RunnerError> {
925    let kill_now = Arc::new(AtomicBool::new(false));
926
927    let stdout = child.stdout.take().expect("stdout was piped");
928    let stdout_kill = kill_now.clone();
929    let max_response = limits.max_response_bytes;
930    let (stdout_done, stdout_result) = std::sync::mpsc::channel();
931    std::thread::spawn(move || {
932        let mut reader = stdout;
933        let end = match protocol::read_frame(&mut reader, max_response) {
934            Ok(FrameRead::Complete(payload)) => {
935                // One frame is the whole grammar. Any byte after it is
936                // a protocol violation and kills the child.
937                let mut probe = [0u8; 1];
938                match reader.read(&mut probe) {
939                    Ok(0) => StdoutEnd::Frame(payload),
940                    Ok(_) => StdoutEnd::Trailing,
941                    Err(e) => StdoutEnd::ReadError(e.kind()),
942                }
943            }
944            Ok(FrameRead::Empty) => StdoutEnd::Empty,
945            Ok(FrameRead::Truncated) => StdoutEnd::Truncated,
946            Ok(FrameRead::Oversized { declared }) => StdoutEnd::Oversized { declared },
947            Err(e) => StdoutEnd::ReadError(e.kind()),
948        };
949        if matches!(
950            end,
951            StdoutEnd::Oversized { .. } | StdoutEnd::Trailing | StdoutEnd::ReadError(_)
952        ) {
953            stdout_kill.store(true, Ordering::SeqCst);
954        }
955        if matches!(end, StdoutEnd::Oversized { .. } | StdoutEnd::Trailing) {
956            // Keep the pipe drained so the child cannot stall on a
957            // full pipe between the violation and the kill. The bytes
958            // are discarded.
959            let mut sink = [0u8; 8192];
960            while matches!(reader.read(&mut sink), Ok(n) if n > 0) {}
961        }
962        let _ = stdout_done.send(end);
963    });
964
965    let stderr = child.stderr.take().expect("stderr was piped");
966    let stderr_kill = kill_now.clone();
967    let max_stderr = limits.max_stderr_bytes;
968    let (stderr_done, stderr_result) = std::sync::mpsc::channel();
969    std::thread::spawn(move || {
970        let mut reader = stderr;
971        let mut kept = Vec::new();
972        let mut total: u64 = 0;
973        let mut chunk = [0u8; 8192];
974        loop {
975            match reader.read(&mut chunk) {
976                Ok(0) => break,
977                Ok(n) => {
978                    total += n as u64;
979                    if kept.len() < 4096 {
980                        let take = (4096 - kept.len()).min(n);
981                        kept.extend_from_slice(&chunk[..take]);
982                    }
983                    if total > max_stderr {
984                        stderr_kill.store(true, Ordering::SeqCst);
985                        // Keep draining so the child cannot stall.
986                    }
987                }
988                Err(_) => break,
989            }
990        }
991        let _ = stderr_done.send((kept, total));
992    });
993
994    let deadline = Instant::now() + limits.wall_timeout;
995    let mut timed_out = false;
996    // The resident-memory guard: while the child runs, the parent
997    // periodically sums resident bytes over the whole process group,
998    // so the engine child and any other descendant are counted, not
999    // just the worker. A crossing kills the group; a failed
1000    // measurement also kills the group and fails closed. The drains
1001    // above keep running through the termination either way.
1002    const MEMORY_POLL_INTERVAL: Duration = Duration::from_millis(100);
1003    let mut next_memory_check = Instant::now() + MEMORY_POLL_INTERVAL;
1004    let mut memory_failure: Option<RunnerError> = None;
1005    let status = loop {
1006        if let Some(status) = child.try_wait().ok().flatten() {
1007            break status;
1008        }
1009        if Instant::now() >= deadline {
1010            timed_out = true;
1011            kill_group(child);
1012            break wait_after_kill(child);
1013        }
1014        if kill_now.load(Ordering::SeqCst) {
1015            kill_group(child);
1016            break wait_after_kill(child);
1017        }
1018        if let Some(ceiling) = limits.max_resident_bytes
1019            && Instant::now() >= next_memory_check
1020        {
1021            next_memory_check = Instant::now() + MEMORY_POLL_INTERVAL;
1022            memory_failure = memory_verdict(memory::group_resident_bytes(child.id()), ceiling);
1023            if memory_failure.is_some() {
1024                kill_group(child);
1025                break wait_after_kill(child);
1026            }
1027        }
1028        std::thread::sleep(Duration::from_millis(2));
1029    };
1030
1031    // One final group measurement after the leader exits and BEFORE
1032    // the unconditional group kill below, so a descendant that
1033    // ballooned and outlived a fast-exiting leader is still measured:
1034    // a clean exit inside the first polling interval must not bypass
1035    // the guard. Over the ceiling discards the response, and a failed
1036    // measurement fails closed, exactly as the polled path does. The
1037    // wall-timeout path keeps its own verdict, and a group the kill
1038    // paths already emptied simply measures zero.
1039    if let Some(ceiling) = limits.max_resident_bytes
1040        && memory_failure.is_none()
1041        && !timed_out
1042    {
1043        memory_failure = memory_verdict(memory::group_resident_bytes(child.id()), ceiling);
1044    }
1045
1046    // The final group kill runs on every exit path, the normal clean
1047    // exit included, so a descendant that closed its stdio and
1048    // lingered past its leader is still killed. The group id stays
1049    // reserved while any member lives, and ESRCH on an empty group is
1050    // fine.
1051    kill_group(child);
1052
1053    // Bounded drain join. On Linux the group kill guarantees EOF,
1054    // because a descendant cannot leave the group. On macOS a
1055    // descendant that changed its session can survive the group kill
1056    // holding a pipe, and this bound is what keeps the parent from
1057    // hanging on it: the drain is detached and the call fails closed.
1058    let join_deadline = Instant::now() + DRAIN_JOIN_BOUND;
1059    let stdout_end = stdout_result.recv_timeout(DRAIN_JOIN_BOUND).ok();
1060    let stderr_end = stderr_result
1061        .recv_timeout(join_deadline.saturating_duration_since(Instant::now()))
1062        .ok();
1063    let (Some(stdout_end), Some((stderr_kept, stderr_total))) = (stdout_end, stderr_end) else {
1064        return Err(RunnerError::new(
1065            "adapter_drain_timeout",
1066            RunnerMessage::DrainTimeout(
1067                u64::try_from(DRAIN_JOIN_BOUND.as_millis()).unwrap_or(u64::MAX),
1068            ),
1069        ));
1070    };
1071
1072    if let Some(failure) = memory_failure {
1073        return Err(failure);
1074    }
1075    if timed_out {
1076        return Err(RunnerError::new(
1077            "adapter_timeout",
1078            RunnerMessage::WallTimeout(
1079                u64::try_from(limits.wall_timeout.as_millis()).unwrap_or(u64::MAX),
1080            ),
1081        ));
1082    }
1083    if let StdoutEnd::Oversized { declared } = stdout_end {
1084        return Err(RunnerError::new(
1085            "adapter_frame_oversized",
1086            RunnerMessage::ResponseFrameOversized {
1087                declared,
1088                ceiling: u64::from(limits.max_response_bytes),
1089            },
1090        ));
1091    }
1092    if stderr_total > limits.max_stderr_bytes {
1093        return Err(RunnerError::new(
1094            "adapter_output_overflow",
1095            RunnerMessage::StderrOverflow {
1096                written: stderr_total,
1097                ceiling: limits.max_stderr_bytes,
1098            },
1099        ));
1100    }
1101    if matches!(stdout_end, StdoutEnd::Trailing) {
1102        return Err(RunnerError::new(
1103            "adapter_output_overflow",
1104            RunnerMessage::TrailingStdout,
1105        ));
1106    }
1107    if !status.success() {
1108        let stderr_text = String::from_utf8_lossy(&stderr_kept);
1109        let stderr_text = scrub_stderr(&stderr_text, limits.max_stderr_bytes);
1110        if !stderr_text.is_empty() {
1111            eprintln!("adapter stderr: {stderr_text}");
1112        }
1113        #[cfg(unix)]
1114        let refusal = helper_refusal(status.code());
1115        #[cfg(not(unix))]
1116        let refusal = None;
1117        if let Some(error) = refusal {
1118            return Err(error);
1119        }
1120        return Err(RunnerError::new(
1121            "adapter_crash",
1122            RunnerMessage::Exit(worker_crash_detail(&status)),
1123        ));
1124    }
1125    let payload = match stdout_end {
1126        StdoutEnd::Frame(payload) => payload,
1127        StdoutEnd::Empty => {
1128            return Err(RunnerError::new(
1129                "adapter_protocol_error",
1130                RunnerMessage::EmptyResponse,
1131            ));
1132        }
1133        StdoutEnd::Truncated => {
1134            return Err(RunnerError::new(
1135                "adapter_protocol_error",
1136                RunnerMessage::TruncatedResponse,
1137            ));
1138        }
1139        StdoutEnd::ReadError(detail) => {
1140            return Err(RunnerError::new(
1141                "adapter_protocol_error",
1142                RunnerMessage::ReadResponse(detail),
1143            ));
1144        }
1145        StdoutEnd::Oversized { .. } | StdoutEnd::Trailing => unreachable!("handled above"),
1146    };
1147    let response: Response = serde_json::from_slice(&payload)
1148        .map_err(|_| RunnerError::new("adapter_protocol_error", RunnerMessage::ParseResponse))?;
1149    response.validate().map_err(|detail| {
1150        RunnerError::new(
1151            "adapter_protocol_error",
1152            RunnerMessage::ValidateResponse(detail),
1153        )
1154    })?;
1155    Ok(response)
1156}
1157
1158/// Replaces each stderr line carrying a path separator before writing
1159/// it to the parent's stderr. Redacting the whole line also covers a
1160/// path with spaces. The returned text stays within the configured
1161/// stderr byte ceiling.
1162fn scrub_stderr(stderr_text: &str, max_bytes: u64) -> String {
1163    const PATH_PLACEHOLDER: &str = "[path]";
1164
1165    let max_bytes = usize::try_from(max_bytes).unwrap_or(usize::MAX);
1166    let mut scrubbed = String::new();
1167    for line in stderr_text
1168        .lines()
1169        .map(str::trim)
1170        .filter(|line| !line.is_empty())
1171    {
1172        let line = if line.contains(['/', '\\']) {
1173            PATH_PLACEHOLDER
1174        } else {
1175            line
1176        };
1177        let separator = usize::from(!scrubbed.is_empty());
1178        if scrubbed
1179            .len()
1180            .saturating_add(separator)
1181            .saturating_add(line.len())
1182            > max_bytes
1183        {
1184            break;
1185        }
1186        if separator == 1 {
1187            scrubbed.push(' ');
1188        }
1189        scrubbed.push_str(line);
1190    }
1191    scrubbed
1192}
1193
1194/// Interprets a helper exit code that refused before any adapter ran.
1195/// A failed jail setup is `sandbox_unavailable`. A process count the
1196/// host would not answer is `process-count-unavailable`, kept apart
1197/// because a spawn budget whose baseline is unknown is refused, not
1198/// guessed at. Any other exit code is the adapter's own.
1199#[cfg_attr(not(unix), allow(dead_code))]
1200fn helper_refusal(exit: Option<i32>) -> Option<RunnerError> {
1201    let exit = exit?;
1202    let (code, detail) = match exit {
1203        SANDBOX_SETUP_EXIT => (
1204            "sandbox_unavailable",
1205            ExitDetail::SandboxSetup(SANDBOX_SETUP_EXIT),
1206        ),
1207        PROCESS_COUNT_EXIT => (
1208            "process-count-unavailable",
1209            ExitDetail::ProcessCount(PROCESS_COUNT_EXIT),
1210        ),
1211        _ => return None,
1212    };
1213    Some(RunnerError::new(code, RunnerMessage::Exit(detail)))
1214}
1215
1216/// Interprets one resident-memory measurement against the ceiling.
1217/// Over the ceiling is `worker-memory-exceeded`; a failed measurement
1218/// is `memory-monitor-failed`, because a guard that cannot see is a
1219/// guard that must fail closed.
1220fn memory_verdict(
1221    measurement: Result<u64, memory::MemoryError>,
1222    ceiling: u64,
1223) -> Option<RunnerError> {
1224    match measurement {
1225        Ok(total) if total > ceiling => Some(RunnerError::new(
1226            "worker-memory-exceeded",
1227            RunnerMessage::MemoryExceeded { total, ceiling },
1228        )),
1229        Ok(_) => None,
1230        Err(error) => Some(RunnerError::new(
1231            "memory-monitor-failed",
1232            RunnerMessage::Memory(error),
1233        )),
1234    }
1235}
1236
1237/// Kills the child's whole process group with SIGKILL.
1238fn kill_group(child: &mut Child) {
1239    #[cfg(unix)]
1240    {
1241        if let Ok(pid) = i32::try_from(child.id()) {
1242            // The child is its own group leader via process_group(0),
1243            // and ESRCH after the group is gone is fine.
1244            let _ = nix::sys::signal::killpg(
1245                nix::unistd::Pid::from_raw(pid),
1246                nix::sys::signal::Signal::SIGKILL,
1247            );
1248        }
1249    }
1250    #[cfg(not(unix))]
1251    {
1252        let _ = child.kill();
1253    }
1254}
1255
1256fn wait_after_kill(child: &mut Child) -> std::process::ExitStatus {
1257    child.wait().unwrap_or_else(|_| {
1258        // The child was already reaped by try_wait.
1259        child
1260            .try_wait()
1261            .ok()
1262            .flatten()
1263            .expect("killed child has an exit status")
1264    })
1265}
1266
1267fn worker_crash_detail(status: &std::process::ExitStatus) -> ExitDetail {
1268    #[cfg(unix)]
1269    {
1270        use std::os::unix::process::ExitStatusExt;
1271        if let Some(signal) = status.signal() {
1272            return ExitDetail::WorkerSignal(signal);
1273        }
1274    }
1275    match status.code() {
1276        Some(code) => ExitDetail::WorkerCode(code),
1277        None => ExitDetail::WorkerNoStatus,
1278    }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283    use super::*;
1284
1285    #[test]
1286    fn a_helper_refusal_is_named_and_never_folded_into_a_crash() {
1287        // A failed jail setup keeps the code it always had.
1288        let setup = helper_refusal(Some(SANDBOX_SETUP_EXIT)).expect("a setup exit is a refusal");
1289        assert_eq!(setup.code, "sandbox_unavailable");
1290        assert_eq!(
1291            setup.message(),
1292            "the sandbox helper refused to engage the jail (exit 71)"
1293        );
1294        // An unmeasurable process count is named on its own, so the
1295        // manifest says why the spawn never happened.
1296        let count = helper_refusal(Some(PROCESS_COUNT_EXIT)).expect("a count exit is a refusal");
1297        assert_eq!(count.code, "process-count-unavailable");
1298        assert_eq!(
1299            count.message(),
1300            "the sandbox helper could not measure the user's process count and refused the spawn (exit 72)"
1301        );
1302        // Everything else is the adapter's own exit, not a refusal.
1303        assert!(helper_refusal(Some(1)).is_none());
1304        assert!(helper_refusal(None).is_none());
1305    }
1306
1307    #[test]
1308    fn stderr_log_scrub_replaces_plain_and_spaced_paths() {
1309        let absolute = "/runtime/worker";
1310        let spaced = "/runtime/private deploy secret";
1311        let detail = format!(
1312            "fixed helper detail\nsandbox_setup_failed: open {absolute}\nsandbox_setup_failed: open {spaced}"
1313        );
1314        let scrubbed = scrub_stderr(&detail, 1024);
1315        assert_eq!(scrubbed, "fixed helper detail [path] [path]");
1316        assert!(!scrubbed.contains(absolute), "{scrubbed}");
1317        for component in spaced.split_whitespace() {
1318            assert!(!scrubbed.contains(component), "{scrubbed}");
1319        }
1320        assert!(scrubbed.len() <= 1024);
1321    }
1322
1323    #[cfg(unix)]
1324    #[test]
1325    fn a_helper_failure_detail_never_enters_a_record_or_bundle() {
1326        use std::os::unix::process::CommandExt;
1327
1328        let marker = format!("spaced_path_{}", std::process::id());
1329        let pieces = [
1330            format!("{marker}_root"),
1331            format!("{marker}_first"),
1332            format!("{marker}_second"),
1333            format!("{marker}_third"),
1334        ];
1335        let private_path = std::path::Path::new("/")
1336            .join(&pieces[0])
1337            .join(format!("{} {} {}", pieces[1], pieces[2], pieces[3]));
1338        let stderr = format!(
1339            "sandbox_setup_failed: cannot open {}",
1340            private_path.display()
1341        );
1342        let mut command = std::process::Command::new("/bin/sh");
1343        command
1344            .arg("-c")
1345            .arg("printf '%s\\n' \"$1\" >&2; exit 71")
1346            .arg("helper")
1347            .arg(stderr)
1348            .stdin(Stdio::null())
1349            .stdout(Stdio::piped())
1350            .stderr(Stdio::piped())
1351            .process_group(0);
1352        let mut child = command.spawn().unwrap();
1353        let failure = drive(&mut child, &Limits::default()).expect_err("the helper refuses");
1354        assert_eq!(failure.code, "sandbox_unavailable");
1355        assert_eq!(
1356            failure.message(),
1357            "the sandbox helper refused to engage the jail (exit 71)"
1358        );
1359
1360        let dir = tempfile::tempdir().unwrap();
1361        let manifest_dir = dir.path().join("manifest");
1362        std::fs::create_dir_all(&manifest_dir).unwrap();
1363        let record = crate::manifest::Record {
1364            schema: crate::manifest::ManifestSchema,
1365            source_path: "input.bin".to_string(),
1366            source_hash: crate::hash::hash_bytes(b"input"),
1367            source_size: 5,
1368            declared_format: None,
1369            detected_format: "unknown".to_string(),
1370            format_mismatch: false,
1371            status: crate::manifest::Status::Failed,
1372            text_path: None,
1373            text_hash: None,
1374            artifact_kind: None,
1375            converter_id: Some("adapter".to_string()),
1376            converter_version: Some("1".to_string()),
1377            tool_version: None,
1378            rules_version: crate::pipeline::Rules::builtin()
1379                .unwrap()
1380                .version()
1381                .to_string(),
1382            media: None,
1383            parent_source: None,
1384            dedup_of: None,
1385            warnings: Vec::new(),
1386            error: Some(failure.to_string()),
1387            duration_ms: Some(0),
1388        };
1389        assert_eq!(
1390            record.error.as_deref(),
1391            Some("sandbox_unavailable: the sandbox helper refused to engage the jail (exit 71)")
1392        );
1393        let serialized = serde_json::to_vec(&record).unwrap();
1394        let shard = manifest_dir.join("alpha.jsonl");
1395        let mut writer = crate::manifest::ManifestWriter::open(&shard).unwrap();
1396        writer.append(&record).unwrap();
1397        drop(writer);
1398
1399        let bundle = dir.path().join("bundle");
1400        crate::bundle::bundle(&crate::bundle::BundleOptions {
1401            mirror_root: &dir.path().join("mirror"),
1402            manifest_dir: &manifest_dir,
1403            division: "alpha",
1404            output: &bundle,
1405        })
1406        .unwrap();
1407
1408        fn assert_absent(bytes: &[u8], piece: &[u8]) {
1409            assert!(
1410                !bytes.windows(piece.len()).any(|window| window == piece),
1411                "a path component reached stored bytes"
1412            );
1413        }
1414
1415        fn check_tree(dir: &std::path::Path, pieces: &[String]) {
1416            for entry in std::fs::read_dir(dir).unwrap() {
1417                let path = entry.unwrap().path();
1418                if path.is_dir() {
1419                    check_tree(&path, pieces);
1420                } else {
1421                    let bytes = std::fs::read(path).unwrap();
1422                    for piece in pieces {
1423                        assert_absent(&bytes, piece.as_bytes());
1424                    }
1425                }
1426            }
1427        }
1428
1429        for piece in &pieces {
1430            assert_absent(&serialized, piece.as_bytes());
1431        }
1432        check_tree(&bundle, &pieces);
1433    }
1434
1435    #[cfg(unix)]
1436    #[test]
1437    fn a_worker_crash_message_carries_only_the_exit_classification() {
1438        use std::os::unix::process::CommandExt;
1439
1440        let marker = format!("worker_path_{}", std::process::id());
1441        let stderr = format!("worker failed while reading /{marker}/with spaces");
1442        let mut command = std::process::Command::new("/bin/sh");
1443        command
1444            .arg("-c")
1445            .arg("printf '%s\\n' \"$1\" >&2; exit 9")
1446            .arg("worker")
1447            .arg(stderr)
1448            .stdin(Stdio::null())
1449            .stdout(Stdio::piped())
1450            .stderr(Stdio::piped())
1451            .process_group(0);
1452        let mut child = command.spawn().unwrap();
1453        let failure = drive(&mut child, &Limits::default()).expect_err("the worker fails");
1454        assert_eq!(failure.code, "adapter_crash");
1455        assert_eq!(
1456            failure.message(),
1457            "worker exited with code 9, any response frame is discarded"
1458        );
1459        assert!(!failure.message().contains(&marker), "{failure}");
1460    }
1461
1462    #[test]
1463    fn a_missing_worker_hash_reports_only_the_error_kind() {
1464        let dir = tempfile::tempdir().unwrap();
1465        let missing = dir.path().join("missing-worker");
1466        let backend = jail::platform_backend().expect("this host has a jail backend");
1467        let error = jail::policy_digest(
1468            backend.as_ref(),
1469            &missing,
1470            &[],
1471            &[],
1472            RuntimeProfile::Plain,
1473            None,
1474        )
1475        .expect_err("the missing worker cannot be hashed");
1476        assert_eq!(error.code, "worker_not_found");
1477        assert_eq!(
1478            error.message(),
1479            "the worker binary cannot be hashed: NotFound"
1480        );
1481        let message = error.message();
1482        for component in missing.components() {
1483            let std::path::Component::Normal(component) = component else {
1484                continue;
1485            };
1486            assert!(
1487                !message
1488                    .as_bytes()
1489                    .windows(component.as_encoded_bytes().len())
1490                    .any(|window| window == component.as_encoded_bytes()),
1491                "a worker path component reached the message"
1492            );
1493        }
1494    }
1495
1496    #[test]
1497    fn the_memory_verdict_kills_over_the_ceiling_and_fails_closed_on_a_query_error() {
1498        // Under the ceiling: no verdict.
1499        assert!(memory_verdict(Ok(100), 200).is_none());
1500        // Exactly at the ceiling: still inside it.
1501        assert!(memory_verdict(Ok(200), 200).is_none());
1502        // Over the ceiling: the group is killed with the stable code.
1503        let over = memory_verdict(Ok(201), 200).expect("over the ceiling is a verdict");
1504        assert_eq!(over.code, "worker-memory-exceeded");
1505        // A failed measurement is a failure of its own, never a pass:
1506        // a guard that cannot see must fail closed.
1507        let failed = memory_verdict(
1508            Err(memory::MemoryError::ProcessTable(
1509                std::io::ErrorKind::PermissionDenied,
1510            )),
1511            200,
1512        )
1513        .expect("a failed measurement is a verdict");
1514        assert_eq!(failed.code, "memory-monitor-failed");
1515        assert_eq!(
1516            failed.message(),
1517            "cannot enumerate the process table: PermissionDenied"
1518        );
1519    }
1520}
1521
1522/// Renders every message variant with boundary values.
1523#[cfg(test)]
1524mod runner_message_tests {
1525    use super::*;
1526    use std::io::ErrorKind;
1527
1528    const RUST_1_88_STABLE_IO_ERROR_KIND_COUNT: usize = 39;
1529    // This table covers every io::ErrorKind stable at the crate MSRV, Rust 1.88.
1530    // Revisit the table when the MSRV changes.
1531    pub(super) const IO_KINDS: &[ErrorKind] = &[
1532        ErrorKind::NotFound,
1533        ErrorKind::PermissionDenied,
1534        ErrorKind::ConnectionRefused,
1535        ErrorKind::ConnectionReset,
1536        ErrorKind::HostUnreachable,
1537        ErrorKind::NetworkUnreachable,
1538        ErrorKind::ConnectionAborted,
1539        ErrorKind::NotConnected,
1540        ErrorKind::AddrInUse,
1541        ErrorKind::AddrNotAvailable,
1542        ErrorKind::NetworkDown,
1543        ErrorKind::BrokenPipe,
1544        ErrorKind::AlreadyExists,
1545        ErrorKind::WouldBlock,
1546        ErrorKind::NotADirectory,
1547        ErrorKind::IsADirectory,
1548        ErrorKind::DirectoryNotEmpty,
1549        ErrorKind::ReadOnlyFilesystem,
1550        ErrorKind::StaleNetworkFileHandle,
1551        ErrorKind::InvalidInput,
1552        ErrorKind::InvalidData,
1553        ErrorKind::TimedOut,
1554        ErrorKind::WriteZero,
1555        ErrorKind::StorageFull,
1556        ErrorKind::NotSeekable,
1557        ErrorKind::QuotaExceeded,
1558        ErrorKind::FileTooLarge,
1559        ErrorKind::ResourceBusy,
1560        ErrorKind::ExecutableFileBusy,
1561        ErrorKind::Deadlock,
1562        ErrorKind::CrossesDevices,
1563        ErrorKind::TooManyLinks,
1564        ErrorKind::InvalidFilename,
1565        ErrorKind::ArgumentListTooLong,
1566        ErrorKind::Interrupted,
1567        ErrorKind::Unsupported,
1568        ErrorKind::UnexpectedEof,
1569        ErrorKind::OutOfMemory,
1570        ErrorKind::Other,
1571    ];
1572
1573    #[test]
1574    fn io_kind_table_covers_every_rust_1_88_stable_kind() {
1575        assert_eq!(IO_KINDS.len(), RUST_1_88_STABLE_IO_ERROR_KIND_COUNT);
1576    }
1577
1578    #[test]
1579    fn every_runner_message_variant_is_path_free() {
1580        const USIZES: &[usize] = &[0, 1, usize::MAX];
1581        const U64S: &[u64] = &[0, 1, u64::MAX];
1582        const U32S: &[u32] = &[0, 1, u32::MAX];
1583        const I32S: &[i32] = &[i32::MIN, -1, 0, 1, i32::MAX];
1584        const PROBE_NAMES: &[ProbeName] = &[
1585            ProbeName::Ipv4Tcp,
1586            ProbeName::Ipv6Tcp,
1587            ProbeName::UnixSocket,
1588            ProbeName::UdpSend,
1589        ];
1590        const PROBE_OUTCOMES: &[ProbeOutcome] = &[
1591            ProbeOutcome::Connected,
1592            ProbeOutcome::Denied,
1593            ProbeOutcome::Timeout,
1594            ProbeOutcome::InvalidTarget,
1595        ];
1596
1597        let render = |message: RunnerMessage| {
1598            let text = message.to_string();
1599            assert!(!text.contains('/'), "separator in runner message: {text}");
1600            assert!(!text.contains('\\'), "separator in runner message: {text}");
1601            assert!(
1602                text.is_ascii(),
1603                "non-ASCII byte in runner message: {text:?}"
1604            );
1605        };
1606
1607        for &kind in IO_KINDS {
1608            render(RunnerMessage::CurrentExecutable(kind));
1609            render(RunnerMessage::WorkerBinaryResolve(kind));
1610            render(RunnerMessage::JailDirectoryCreate(kind));
1611            render(RunnerMessage::JailDirectoryCanonicalize(kind));
1612            render(RunnerMessage::StageInput(kind));
1613            render(RunnerMessage::SpawnWorker(kind));
1614            render(RunnerMessage::ReadResponse(kind));
1615            render(RunnerMessage::WorkerHash(kind));
1616            render(RunnerMessage::ProbeListener(kind));
1617            for &grant in &[GrantKind::Executable, GrantKind::Read] {
1618                render(RunnerMessage::Grant {
1619                    kind: grant,
1620                    position: usize::MAX,
1621                    count: usize::MAX,
1622                    fault: GrantFault::Io(kind),
1623                });
1624            }
1625            for &name in PROBE_NAMES {
1626                for &outcome in PROBE_OUTCOMES {
1627                    render(RunnerMessage::ProbeAttempt {
1628                        name,
1629                        outcome,
1630                        error_kind: kind,
1631                    });
1632                }
1633            }
1634            for error in [
1635                memory::MemoryError::ProcessTable(kind),
1636                memory::MemoryError::ProcessGroup(kind),
1637                memory::MemoryError::ProcessGroupRecheck(kind),
1638                memory::MemoryError::ProcessRecords(u32::MAX, kind),
1639                memory::MemoryError::ProcessRecheck(u32::MAX, kind),
1640            ] {
1641                render(RunnerMessage::Memory(error));
1642            }
1643        }
1644
1645        render(RunnerMessage::CurrentExecutableHasNoDirectory);
1646        render(RunnerMessage::WorkerBinaryMissing);
1647        render(RunnerMessage::InputNameNotBare);
1648        render(RunnerMessage::TrailingStdout);
1649        render(RunnerMessage::EmptyResponse);
1650        render(RunnerMessage::TruncatedResponse);
1651        render(RunnerMessage::ParseResponse);
1652        render(RunnerMessage::ProbeRequestEncode);
1653        render(RunnerMessage::ProbeReportedFailure);
1654        render(RunnerMessage::ProbeResponseParse);
1655        render(RunnerMessage::ServiceNamespace);
1656        render(RunnerMessage::TempDirectoryVariable);
1657        render(RunnerMessage::PlatformBackendMissing);
1658        render(RunnerMessage::ProviderNeedsWiring);
1659        render(RunnerMessage::SandboxHelperMissing);
1660        render(RunnerMessage::ProviderPlatformUnsupported);
1661        render(RunnerMessage::SyscallProbeAllowed);
1662        render(RunnerMessage::SyscallProbeFailed);
1663
1664        for &value in USIZES {
1665            for &kind in &[GrantKind::Executable, GrantKind::Read] {
1666                for &fault in &[GrantFault::Symlink, GrantFault::NotRegular] {
1667                    render(RunnerMessage::Grant {
1668                        kind,
1669                        position: value,
1670                        count: value,
1671                        fault,
1672                    });
1673                }
1674            }
1675            for &fault in &[
1676                ClosureFault::NotAbsolute,
1677                ClosureFault::NotNormal,
1678                ClosureFault::Symlink,
1679                ClosureFault::SharedRoot,
1680                ClosureFault::Unresolved,
1681                ClosureFault::NotFileOrDirectory,
1682                ClosureFault::ResolvedSharedRoot,
1683            ] {
1684                render(RunnerMessage::Closure {
1685                    position: value,
1686                    count: value,
1687                    fault,
1688                });
1689            }
1690            render(RunnerMessage::ClosureNotUtf8 {
1691                position: value,
1692                count: value,
1693            });
1694            render(RunnerMessage::ClosureCannotBeQuoted {
1695                position: value,
1696                count: value,
1697            });
1698        }
1699
1700        for &value in U64S {
1701            render(RunnerMessage::DrainTimeout(value));
1702            render(RunnerMessage::WallTimeout(value));
1703            render(RunnerMessage::ResponseFrameOversized {
1704                declared: value,
1705                ceiling: value,
1706            });
1707            render(RunnerMessage::StderrOverflow {
1708                written: value,
1709                ceiling: value,
1710            });
1711            render(RunnerMessage::MemoryExceeded {
1712                total: value,
1713                ceiling: value,
1714            });
1715        }
1716
1717        for &value in U32S {
1718            for error in [
1719                memory::MemoryError::GroupLeaderPid(value),
1720                memory::MemoryError::GroupMemberPid(value),
1721                memory::MemoryError::ResidentSizeRead(value),
1722                memory::MemoryError::StatRecord(value),
1723            ] {
1724                render(RunnerMessage::Memory(error));
1725            }
1726        }
1727        for error in [
1728            memory::MemoryError::ResidentSizeSum,
1729            memory::MemoryError::ResidentSizeValue,
1730            memory::MemoryError::ResidentSizeUnit,
1731            memory::MemoryError::ResidentSizeScale,
1732            memory::MemoryError::MonitorUnavailable,
1733        ] {
1734            render(RunnerMessage::Memory(error));
1735        }
1736
1737        for &value in I32S {
1738            render(RunnerMessage::Exit(ExitDetail::SandboxSetup(value)));
1739            render(RunnerMessage::Exit(ExitDetail::ProcessCount(value)));
1740            render(RunnerMessage::Exit(ExitDetail::WorkerCode(value)));
1741            render(RunnerMessage::Exit(ExitDetail::WorkerSignal(value)));
1742        }
1743        render(RunnerMessage::Exit(ExitDetail::WorkerNoStatus));
1744
1745        for detail in [
1746            ResponseValidationError::BothBodies,
1747            ResponseValidationError::NoBody,
1748        ] {
1749            render(RunnerMessage::ValidateResponse(detail));
1750        }
1751        for &name in PROBE_NAMES {
1752            render(RunnerMessage::ProbeAttemptMissing(name));
1753        }
1754        for value in [
1755            TemplateValue::Jail,
1756            TemplateValue::Worker,
1757            TemplateValue::Closures,
1758            TemplateValue::Service,
1759            TemplateValue::Grants,
1760        ] {
1761            render(RunnerMessage::TemplateBrace(value));
1762        }
1763        for class in [
1764            PathClass::Jail,
1765            PathClass::Worker,
1766            PathClass::ExecutableGrantDirectory,
1767            PathClass::ExecutableGrant,
1768            PathClass::ReadGrant,
1769        ] {
1770            render(RunnerMessage::PathNotUtf8(class));
1771            render(RunnerMessage::PathCannotBeQuoted(class));
1772        }
1773    }
1774}