char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somrearrange libs into lib and extensions into extsf4a0f5b

main
33.3 KiB957 linesraw
1use crate::scope::Escalation;
2use crate::{self as sys, Child, Scope};
3use avril_caps::{Capabilities, PathTarget, env_name};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6use std::ffi::{OsStr, OsString};
7use std::io;
8use std::path::{Path, PathBuf};
9use std::pin::Pin;
10use std::process::ExitStatus;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::sync::{Arc, Condvar, Mutex};
13use std::time::{Duration, Instant};
14use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
15use tokio::runtime::Handle;
16use tokio::sync::{Mutex as AsyncMutex, Notify};
17use tokio_util::sync::CancellationToken;
18
19type Reader = Pin<Box<dyn AsyncRead + Send>>;
20type Writer = Pin<Box<dyn AsyncWrite + Send>>;
21const CHUNK: usize = 64 * 1024;
22fn invalid(message: &str) -> io::Error {
23    io::Error::new(io::ErrorKind::InvalidInput, message)
24}
25fn missing() -> io::Error {
26    io::Error::new(io::ErrorKind::NotConnected, "Subprocess handle is closed")
27}
28fn native_env_name(name: &OsStr) -> OsString {
29    if cfg!(windows) {
30        name.to_ascii_uppercase()
31    } else {
32        name.to_owned()
33    }
34}
35
36#[derive(Default, Deserialize)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct Options {
39    pub command: String,
40    pub args: Vec<String>,
41    pub cwd: Option<PathBuf>,
42    pub search_path: bool,
43    pub clear_env: bool,
44    pub env: Vec<(String, String)>,
45    pub stdin: StdioSpec,
46    pub stdout: StdioSpec,
47    pub stderr: StdioSpec,
48    pub extra_stdio: Vec<(u32, StdioSpec)>,
49    pub terminal: Option<TerminalSize>,
50    pub timeout_ms: Option<u64>,
51    /// Caps bytes produced on outputs collected natively, which only runSync
52    /// does; JS enforces the collected limit everywhere else.
53    pub max_collected_bytes: Option<u64>,
54    pub max_buffered_bytes: u64,
55    pub termination: Termination,
56    pub drain_timeout_ms: u64,
57    /// Outputs whose reader keeps up deliberately: at the unread limit the
58    /// pump stops reading, so the child blocks instead of being terminated.
59    #[serde(default)]
60    pub backpressure: Vec<u32>,
61}
62impl Options {
63    pub fn new(command: impl Into<String>, args: Vec<String>) -> Self {
64        Self {
65            command: command.into(),
66            args,
67            search_path: true,
68            max_buffered_bytes: 1024 * 1024,
69            drain_timeout_ms: 1000,
70            termination: Termination {
71                scope: "group".into(),
72                signal: "SIGTERM".into(),
73                grace_ms: 1000,
74            },
75            ..Self::default()
76        }
77    }
78    fn stdio(&self) -> impl Iterator<Item = (u32, &StdioSpec, bool)> {
79        let standard = [
80            (0, &self.stdin, true),
81            (1, &self.stdout, false),
82            (2, &self.stderr, false),
83        ];
84        let extra = self.extra_stdio.iter().map(|(fd, spec)| {
85            (
86                *fd,
87                spec,
88                spec.direction.as_deref() == Some("parent-to-child"),
89            )
90        });
91        standard.into_iter().chain(extra)
92    }
93}
94#[derive(Deserialize)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct StdioSpec {
97    pub mode: String,
98    pub file: Option<String>,
99    pub file_mode: Option<String>,
100    pub handle: Option<u64>,
101    pub direction: Option<String>,
102}
103impl Default for StdioSpec {
104    fn default() -> Self {
105        Self {
106            mode: "inherit".into(),
107            file: None,
108            file_mode: None,
109            handle: None,
110            direction: None,
111        }
112    }
113}
114#[derive(Deserialize)]
115pub struct TerminalSize {
116    pub columns: u16,
117    pub rows: u16,
118}
119#[derive(Default, Deserialize)]
120#[serde(rename_all = "camelCase", deny_unknown_fields)]
121pub struct Termination {
122    pub scope: String,
123    pub signal: String,
124    pub grace_ms: u64,
125}
126
127enum StdioPlan<'a> {
128    Inherit,
129    Null,
130    Pipe,
131    Stdout,
132    File(&'a StdioSpec),
133    Handle(&'a StdioSpec),
134}
135
136enum TerminationScope {
137    Child,
138    Group,
139}
140
141struct SpawnPlan<'a> {
142    stdio: Vec<(u32, StdioPlan<'a>, bool)>,
143    scope: TerminationScope,
144    terminal: Option<&'a TerminalSize>,
145}
146#[derive(Clone, Serialize)]
147pub struct Status {
148    pub code: Option<i32>,
149    pub signal: Option<String>,
150    pub success: bool,
151    pub reason: Option<String>,
152    pub error: Option<String>,
153}
154#[derive(Default)]
155struct Queue {
156    bytes: bytes::BytesMut,
157    closed: bool,
158}
159#[derive(Default)]
160pub struct Output {
161    queue: Mutex<Queue>,
162    notify: Notify,
163    backpressure: bool,
164    /// Signals the pump when a backpressured reader frees queue space.
165    space: Notify,
166}
167impl Output {
168    fn new(backpressure: bool) -> Self {
169        Self { backpressure, ..Self::default() }
170    }
171    pub async fn read(&self) -> Option<bytes::Bytes> {
172        loop {
173            let wake = self.notify.notified();
174            tokio::pin!(wake);
175            wake.as_mut().enable();
176            {
177                let mut queue = self.queue.lock().unwrap();
178                if !queue.bytes.is_empty() {
179                    let count = queue.bytes.len().min(CHUNK);
180                    let bytes = queue.bytes.split_to(count).freeze();
181                    self.space.notify_one();
182                    return Some(bytes);
183                }
184                if queue.closed {
185                    return None;
186                }
187            }
188            wake.await;
189        }
190    }
191    pub fn cancel(&self) {
192        let mut queue = self.queue.lock().unwrap();
193        queue.closed = true;
194        queue.bytes = bytes::BytesMut::new();
195        self.notify.notify_waiters();
196        self.space.notify_one();
197    }
198}
199pub struct Input {
200    writer: AsyncMutex<Option<Writer>>,
201    stop: CancellationToken,
202}
203impl Input {
204    pub async fn write(&self, bytes: Vec<u8>) -> io::Result<()> {
205        tokio::select! {
206            biased;
207            _ = self.stop.cancelled() => Err(io::Error::new(io::ErrorKind::BrokenPipe, "Subprocess input is closed")),
208            result = async { self.writer.lock().await.as_mut().ok_or_else(missing)?.write_all(&bytes).await } => result,
209        }
210    }
211    pub async fn close(&self) {
212        self.stop.cancel();
213        self.writer.lock().await.take();
214    }
215}
216struct Core {
217    child: Option<Child>,
218    exit: Option<ExitStatus>,
219    completed: bool,
220    reason: Option<String>,
221    error: Option<String>,
222    stop: Escalation,
223    epoch: u32,
224    produced: u64,
225}
226impl Core {
227    fn observe(&mut self) -> io::Result<bool> {
228        if self.exit.is_some() {
229            return Ok(false);
230        }
231        self.exit = self.child.as_ref().map_or(Ok(None), Child::observe_exit)?;
232        Ok(self.exit.is_some())
233    }
234    fn report(&mut self, reason: &str, error: Option<String>) {
235        if self.reason.is_none() {
236            self.reason = Some(reason.into());
237            self.error = error;
238        }
239    }
240}
241pub struct Process {
242    pub pid: u32,
243    group: bool,
244    drain: Duration,
245    timeout: Option<Duration>,
246    limit: Option<u64>,
247    pending_limit: u64,
248    core: Mutex<Core>,
249    done: Condvar,
250    changed: Notify,
251    wake: Notify,
252    pipe_stop: CancellationToken,
253    pub outputs: BTreeMap<u32, Arc<Output>>,
254    pub inputs: BTreeMap<u32, Arc<Input>>,
255    pumps: AtomicUsize,
256    /// Pumps waiting for a backpressured reader to free space.
257    parked: AtomicUsize,
258    owners: Vec<CancellationToken>,
259    window: Option<sys::Window>,
260}
261impl Process {
262    pub fn wait_blocking(&self, timeout: Duration) {
263        let core = self.core.lock().unwrap();
264        let _ = self
265            .done
266            .wait_timeout_while(core, timeout, |core| !core.completed);
267    }
268    pub fn invalidate_waiters(&self) -> u32 {
269        let mut core = self.core.lock().unwrap();
270        core.epoch = core.epoch.wrapping_add(1);
271        self.changed.notify_waiters();
272        core.epoch
273    }
274    pub fn resize(&self, columns: u16, rows: u16) -> io::Result<()> {
275        sys::resize(
276            self.window
277                .as_ref()
278                .ok_or_else(|| invalid("Subprocess has no terminal"))?,
279            columns,
280            rows,
281        )
282    }
283
284    fn status(core: &Core) -> Option<Status> {
285        let exit = core.exit?;
286        Some(Status {
287            code: exit.code(),
288            signal: sys::exit_signal(&exit),
289            success: exit.success() && core.reason.is_none(),
290            reason: core.reason.clone(),
291            error: core.error.clone(),
292        })
293    }
294    pub fn request(&self, reason: &str, error: Option<String>, force: bool) {
295        let mut core = self.core.lock().unwrap();
296        if core.completed {
297            return;
298        }
299        // Supervision polls for owner shutdown, so repeat requests must not
300        // re-notify and spin the supervisor into starving pipe tasks.
301        core.report(reason, error);
302        let changed = core.stop.request(force);
303        if changed {
304            self.wake.notify_one();
305        }
306    }
307    /// Synchronously SIGKILLs the termination scope. Used on the dying path
308    /// so grace-resisting descendants cannot outlive the owner.
309    pub fn kill_scope(&self) {
310        let mut core = self.core.lock().unwrap();
311        let _ = self.signal_locked(&mut core, "SIGKILL");
312    }
313    fn signal_locked(&self, core: &mut Core, signal: &str) -> io::Result<bool> {
314        match &core.child {
315            Some(child) => child.signal(self.group, signal),
316            None => Ok(false),
317        }
318    }
319    pub fn kill(&self, signal: &str) -> io::Result<bool> {
320        let mut core = self.core.lock().unwrap();
321        if core.exit.is_some() || core.completed {
322            return Ok(false);
323        }
324        if core.observe()? {
325            self.changed.notify_waiters();
326            return Ok(false);
327        }
328        self.signal_locked(&mut core, signal)
329    }
330    pub async fn wait(&self, completed: bool, epoch: Option<u32>) -> io::Result<Option<Status>> {
331        loop {
332            let wake = self.changed.notified();
333            tokio::pin!(wake);
334            wake.as_mut().enable();
335            {
336                let core = self.core.lock().unwrap();
337                if epoch.is_some_and(|epoch| epoch != core.epoch) {
338                    return Ok(None);
339                }
340                if (!completed && core.exit.is_some()) || core.completed {
341                    return Ok(Self::status(&core));
342                }
343            }
344            wake.await;
345        }
346    }
347    /// Waits while a backpressured output is full; false once pipes stop.
348    async fn room(&self, output: &Output) -> bool {
349        loop {
350            let space = output.space.notified();
351            tokio::pin!(space);
352            space.as_mut().enable();
353            {
354                let queue = output.queue.lock().unwrap();
355                if queue.closed || (queue.bytes.len() as u64) < self.pending_limit {
356                    return true;
357                }
358            }
359            self.parked.fetch_add(1, Ordering::AcqRel);
360            self.wake.notify_one();
361            let stopped = tokio::select! {
362                biased;
363                _ = self.pipe_stop.cancelled() => true,
364                _ = space => false,
365            };
366            self.parked.fetch_sub(1, Ordering::AcqRel);
367            self.wake.notify_one();
368            if stopped {
369                return false;
370            }
371        }
372    }
373    async fn pump(self: Arc<Self>, output: Arc<Output>, mut reader: Reader) {
374        let mut bytes = vec![0; CHUNK];
375        loop {
376            if output.backpressure && !self.room(&output).await {
377                break;
378            }
379            let count = tokio::select! { biased; _ = self.pipe_stop.cancelled() => break,
380            result = reader.read(&mut bytes) => match result { Ok(0) => break, Ok(count) => count,
381                Err(error) => { self.request("io-error", Some(error.to_string()), false); break; } } };
382            let overflow;
383            {
384                let mut core = self.core.lock().unwrap();
385                let mut queue = output.queue.lock().unwrap();
386                if core.reason.as_deref() == Some("output-limit") {
387                    continue;
388                }
389                let allowed = self
390                    .limit
391                    .map_or(count as u64, |limit| limit.saturating_sub(core.produced))
392                    .min(count as u64);
393                core.produced = core.produced.saturating_add(count as u64);
394                let allowed = if queue.closed || output.backpressure {
395                    allowed
396                } else {
397                    allowed.min(self.pending_limit.saturating_sub(queue.bytes.len() as u64))
398                };
399                overflow = allowed < count as u64;
400                if !queue.closed && allowed > 0 {
401                    queue.bytes.extend_from_slice(&bytes[..allowed as usize]);
402                }
403            }
404            output.notify.notify_waiters();
405            if overflow {
406                self.request(
407                    "output-limit",
408                    Some("Subprocess output exceeded its configured limit".into()),
409                    false,
410                );
411            }
412        }
413        output.queue.lock().unwrap().closed = true;
414        output.notify.notify_waiters();
415        self.pumps.fetch_sub(1, Ordering::AcqRel);
416        self.wake.notify_one();
417    }
418    async fn supervise(self: Arc<Self>) {
419        let started = Instant::now();
420        let mut drain_from = None;
421        loop {
422            if self.owners.iter().any(CancellationToken::is_cancelled) {
423                self.request("owner", None, true);
424            }
425            {
426                let mut core = self.core.lock().unwrap();
427                match core.observe() {
428                    Ok(true) => self.changed.notify_waiters(),
429                    Ok(false) => (),
430                    Err(error) if error.kind() == io::ErrorKind::Interrupted => (),
431                    Err(error) => {
432                        // waitid only fails persistently when the child is already
433                        // gone (ECHILD) or the arguments are invalid; either way no
434                        // exit will ever be observed, so drain pipes and finish
435                        // instead of spinning forever.
436                        core.report("io-error", Some(error.to_string()));
437                        core.stop.request(false);
438                        drain_from.get_or_insert_with(Instant::now);
439                    }
440                }
441                if core.exit.is_some() {
442                    drain_from.get_or_insert_with(Instant::now);
443                }
444                if core.reason.is_none()
445                    && core.exit.is_none()
446                    && self.timeout.is_some_and(|limit| started.elapsed() >= limit)
447                {
448                    core.reason = Some("timeout".into());
449                    core.stop.request(false);
450                }
451                let Core { child, stop, .. } = &mut *core;
452                if let Err(error) = stop.advance(|signal| {
453                    child
454                        .as_ref()
455                        .map_or(Ok(false), |child| child.signal(self.group, signal))
456                }) {
457                    core.error.get_or_insert(error.to_string());
458                }
459                if let Some(since) = &mut drain_from {
460                    let pumps = self.pumps.load(Ordering::Acquire);
461                    // A natural run's pumps waiting on a slow reader are not
462                    // pipes held open by stragglers; the drain clock restarts
463                    // once they move again.
464                    if core.reason.is_none()
465                        && pumps != 0
466                        && self.parked.load(Ordering::Acquire) == pumps
467                    {
468                        *since = Instant::now();
469                    }
470                    // Keep the leader unreaped until group cleanup, so its PID cannot be reused.
471                    if since.elapsed() >= self.drain && pumps != 0 {
472                        if self.group {
473                            let _ = self.signal_locked(&mut core, "SIGKILL");
474                        }
475                        core.report(
476                            "drain-timeout",
477                            Some("Subprocess pipes remained open after exit".into()),
478                        );
479                        self.pipe_stop.cancel();
480                    }
481                    if self.pumps.load(Ordering::Acquire) == 0 {
482                        if self.group {
483                            let _ = self.signal_locked(&mut core, "SIGKILL");
484                        }
485                        break;
486                    }
487                }
488                if core.stop.forced {
489                    self.pipe_stop.cancel();
490                }
491            }
492            if drain_from.is_some() {
493                for input in self.inputs.values() {
494                    input.close().await;
495                }
496            }
497            tokio::select! { _ = self.wake.notified() => (), _ = tokio::time::sleep(Duration::from_millis(5)) => () }
498        }
499        for input in self.inputs.values() {
500            input.close().await;
501        }
502        let mut core = self.core.lock().unwrap();
503        if let Some(mut child) = core.child.take()
504            && let Err(error) = child.wait()
505        {
506            core.error.get_or_insert(error.to_string());
507        }
508        core.completed = true;
509        self.changed.notify_waiters();
510        self.done.notify_all();
511    }
512}
513
514pub struct Launcher {
515    pub runtime: Handle,
516    pub caps: Arc<Capabilities>,
517    pub owners: Vec<CancellationToken>,
518}
519impl Launcher {
520    pub fn spawn(
521        &self,
522        options: Options,
523        environment: impl IntoIterator<Item = (OsString, OsString)>,
524        scope: Option<&Scope>,
525        file: impl Fn(&StdioSpec, bool, &Path) -> io::Result<std::fs::File>,
526    ) -> io::Result<Arc<Process>> {
527        let plan = validate(&options)?;
528        if self.owners.iter().any(CancellationToken::is_cancelled) {
529            return Err(missing());
530        }
531        let _guard = self.runtime.enter();
532        let cwd = options.cwd.as_ref().map_or_else(
533            || self.caps.cwd().to_owned(),
534            |cwd| self.caps.cwd().join(cwd),
535        );
536        // Restricted denials share one message so success and failure cannot
537        // be used to probe whether directories exist.
538        let cwd = cwd.canonicalize().map_err(|error| {
539            if self.caps.unrestricted_run() {
540                error
541            } else {
542                denied()
543            }
544        })?;
545        let mut env = BTreeMap::new();
546        if !options.clear_env {
547            for (name, value) in environment {
548                if self.caps.unrestricted_env()
549                    || name
550                        .to_str()
551                        .is_some_and(|name| self.caps.check_env(name).is_ok())
552                {
553                    env.insert(native_env_name(&name), (name, value));
554                }
555            }
556        }
557        for (name, value) in &options.env {
558            env.insert(
559                native_env_name(OsStr::new(name)),
560                (name.into(), value.into()),
561            );
562        }
563        let path = env
564            .get(&native_env_name(OsStr::new("PATH")))
565            .map(|(_, value)| value.as_os_str());
566        let (executable, target) = resolve(
567            &options.command,
568            options.search_path,
569            &cwd,
570            path,
571            &self.caps,
572        )?;
573        // A child that inherits standard streams joins the parent's process
574        // group instead of its own, but only with a terminal attached: only
575        // the foreground group can use it (inherited TTYs, or /dev/tty
576        // opened by the child), and seizing the foreground here would
577        // suspend this process when restoring it. Headless setups keep
578        // their own group so timeouts and kills reach all descendants.
579        // Windows consoles deliver to every attached process regardless,
580        // so job objects never need to give way.
581        let shares_terminal = cfg!(unix)
582            && matches!(plan.scope, TerminationScope::Group)
583            && plan.terminal.is_none()
584            && {
585                use std::io::IsTerminal;
586                // Options::stdio() yields fds 0, 1, 2 first and in order,
587                // so the leading plan entries are the standard streams.
588                let inherited = [
589                    matches!(plan.stdio[0].1, StdioPlan::Inherit),
590                    matches!(plan.stdio[1].1, StdioPlan::Inherit),
591                    matches!(plan.stdio[2].1, StdioPlan::Inherit),
592                ];
593                let terminals = [
594                    std::io::stdin().is_terminal(),
595                    std::io::stdout().is_terminal(),
596                    std::io::stderr().is_terminal(),
597                ];
598                inherited.contains(&true)
599                    && (inherited
600                        .into_iter()
601                        .zip(terminals)
602                        .any(|(inherit, tty)| inherit && tty)
603                        || sys::controlling_foreground())
604            };
605        let group =
606            scope.is_none() && matches!(plan.scope, TerminationScope::Group) && !shares_terminal;
607        let mut readers: BTreeMap<u32, Reader> = BTreeMap::new();
608        let mut writers: BTreeMap<u32, Writer> = BTreeMap::new();
609        let mut endpoints = BTreeMap::new();
610        let window = if let Some(size) = plan.terminal {
611            let pty = sys::pty(size.columns, size.rows)?;
612            readers.insert(1, Box::pin(pty.reader));
613            writers.insert(0, Box::pin(pty.writer));
614            for (fd, endpoint) in pty.endpoints.into_iter().enumerate() {
615                endpoints.insert(fd as u32, endpoint);
616            }
617            Some(pty.window)
618        } else {
619            for (fd, kind, read) in &plan.stdio {
620                let endpoint = match kind {
621                    StdioPlan::Pipe => {
622                        let (pipe, endpoint) = sys::pipe(*read)?;
623                        if *read {
624                            writers.insert(*fd, Box::pin(pipe));
625                        } else {
626                            readers.insert(*fd, Box::pin(pipe));
627                        }
628                        endpoint
629                    }
630                    StdioPlan::Null => sys::null(*read)?,
631                    StdioPlan::Inherit => sys::inherit(*fd)?,
632                    StdioPlan::Stdout => sys::duplicate(&endpoints[&1])?,
633                    StdioPlan::File(spec) | StdioPlan::Handle(spec) => {
634                        file(spec, *read, &cwd)?.into()
635                    }
636                };
637                endpoints.insert(*fd, endpoint);
638            }
639            None
640        };
641        if options
642            .backpressure
643            .iter()
644            .any(|fd| !readers.contains_key(fd))
645        {
646            return Err(invalid("Backpressure requires a piped output"));
647        }
648        self.caps.check_run(&target).map_err(|_| denied())?;
649        if self.owners.iter().any(CancellationToken::is_cancelled) {
650            return Err(missing());
651        }
652        let launch = sys::Launch {
653            executable,
654            program: target.as_path(),
655            args: &options.args,
656            env: env.into_values().collect(),
657            cwd: &cwd,
658            group,
659            join: None,
660            terminal: window.is_some(),
661            endpoints: endpoints.into_iter().collect(),
662        };
663        let child = match &scope {
664            Some(scope) => scope.spawn(launch)?,
665            None => sys::spawn(launch)?,
666        };
667        let pid = child.id();
668        let outputs: BTreeMap<_, _> = readers
669            .keys()
670            .map(|fd| {
671                (
672                    *fd,
673                    Arc::new(Output::new(options.backpressure.contains(fd))),
674                )
675            })
676            .collect();
677        let inputs = writers
678            .into_iter()
679            .map(|(fd, writer)| {
680                (
681                    fd,
682                    Arc::new(Input {
683                        writer: AsyncMutex::new(Some(writer)),
684                        stop: CancellationToken::new(),
685                    }),
686                )
687            })
688            .collect();
689        let process = Arc::new(Process {
690            group,
691            drain: Duration::from_millis(options.drain_timeout_ms),
692            timeout: options.timeout_ms.map(Duration::from_millis),
693            limit: options.max_collected_bytes,
694            pending_limit: options.max_buffered_bytes,
695            core: Mutex::new(Core {
696                child: Some(child),
697                exit: None,
698                completed: false,
699                reason: None,
700                error: None,
701                stop: Escalation::new(vec![(
702                    options.termination.signal,
703                    Duration::from_millis(options.termination.grace_ms),
704                )]),
705                epoch: 0,
706                produced: 0,
707            }),
708            done: Condvar::new(),
709            changed: Notify::new(),
710            wake: Notify::new(),
711            pipe_stop: CancellationToken::new(),
712            pumps: AtomicUsize::new(readers.len()),
713            parked: AtomicUsize::new(0),
714            owners: self.owners.clone(),
715            pid,
716            outputs,
717            inputs,
718            window,
719        });
720        for (fd, reader) in readers {
721            self.runtime
722                .spawn(process.clone().pump(process.outputs[&fd].clone(), reader));
723        }
724        self.runtime.spawn(process.clone().supervise());
725        Ok(process)
726    }
727}
728fn validate(options: &Options) -> io::Result<SpawnPlan<'_>> {
729    if options.command.is_empty()
730        || options.command.contains('\0')
731        || options.args.iter().any(|arg| arg.contains('\0'))
732    {
733        return Err(invalid("Commands and arguments must not contain NUL"));
734    }
735    if options.cwd.as_ref().is_some_and(|cwd| {
736        cwd.as_os_str().is_empty() || cwd.as_os_str().as_encoded_bytes().contains(&0)
737    }) {
738        return Err(invalid("Invalid subprocess cwd"));
739    }
740    for (name, value) in &options.env {
741        env_name(name).map_err(|e| invalid(&e.to_string()))?;
742        if value.contains('\0') {
743            return Err(invalid("Environment values must not contain NUL"));
744        }
745    }
746    if options.max_buffered_bytes == 0
747        || options.max_buffered_bytes > (1 << 53) - 1
748        || options
749            .max_collected_bytes
750            .is_some_and(|limit| limit == 0 || limit > (1 << 53) - 1)
751    {
752        return Err(invalid("Invalid subprocess output limit"));
753    }
754    let scope = match options.termination.scope.as_str() {
755        "group" => TerminationScope::Group,
756        "child" => TerminationScope::Child,
757        _ => return Err(invalid("Invalid termination scope")),
758    };
759    sys::validate_signal(&options.termination.signal)?;
760    fn validate_spec(
761        spec: &StdioSpec,
762        read: bool,
763        stderr: bool,
764        extra: bool,
765    ) -> io::Result<StdioPlan<'_>> {
766        let plan = match spec.mode.as_str() {
767            "inherit" => StdioPlan::Inherit,
768            "null" => StdioPlan::Null,
769            "piped" => StdioPlan::Pipe,
770            "stdout" if stderr => StdioPlan::Stdout,
771            "file" => StdioPlan::File(spec),
772            "handle" => StdioPlan::Handle(spec),
773            _ => return Err(invalid("Invalid stdio mode")),
774        };
775        if matches!(plan, StdioPlan::File(_)) {
776            if spec
777                .file
778                .as_ref()
779                .is_none_or(|path| path.is_empty() || path.contains('\0'))
780                || spec.handle.is_some()
781            {
782                return Err(invalid("Invalid stdio file"));
783            }
784            if read {
785                if spec.file_mode.is_some() {
786                    return Err(invalid("Input file accepts no file mode"));
787                }
788            } else if !matches!(
789                spec.file_mode.as_deref(),
790                Some("append" | "truncate" | "create-new")
791            ) {
792                return Err(invalid("Invalid output file mode"));
793            }
794        } else if spec.file.is_some()
795            || spec.file_mode.is_some()
796            || matches!(plan, StdioPlan::Handle(_)) != spec.handle.is_some()
797        {
798            return Err(invalid("Invalid stdio specification"));
799        }
800        if extra {
801            if matches!(plan, StdioPlan::Inherit) {
802                return Err(invalid("Extra stdio cannot inherit internal descriptors"));
803            }
804            if !matches!(
805                spec.direction.as_deref(),
806                Some("parent-to-child" | "child-to-parent")
807            ) {
808                return Err(invalid("Extra stdio requires a direction"));
809            }
810        } else if spec.direction.is_some() {
811            return Err(invalid("Unexpected stdio direction"));
812        }
813        Ok(plan)
814    }
815    let mut descriptors = BTreeSet::new();
816    let mut stdio = Vec::new();
817    for (fd, spec, read) in options.stdio() {
818        if fd > sys::MAX_DESCRIPTOR || !descriptors.insert(fd) {
819            return Err(invalid("Invalid additional descriptor"));
820        }
821        let plan = validate_spec(spec, read, fd == 2, fd >= 3)?;
822        stdio.push((fd, plan, read));
823    }
824    if let Some(window) = &options.terminal
825        && (window.columns == 0
826            || window.rows == 0
827            || !matches!(stdio[0].1, StdioPlan::Pipe)
828            || !matches!(stdio[1].1, StdioPlan::Pipe)
829            || !matches!(stdio[2].1, StdioPlan::Stdout)
830            || !options.extra_stdio.is_empty())
831    {
832        return Err(invalid("Terminal stdio must be piped and merged"));
833    }
834    Ok(SpawnPlan {
835        stdio,
836        scope,
837        terminal: options.terminal.as_ref(),
838    })
839}
840
841/// Restricted launch denials share one message so they cannot be used to
842/// probe whether directories or executables exist.
843fn denied() -> io::Error {
844    io::Error::new(
845        io::ErrorKind::PermissionDenied,
846        "Executable is not authorized",
847    )
848}
849
850fn resolve(
851    command: &str,
852    search: bool,
853    cwd: &Path,
854    path: Option<&OsStr>,
855    caps: &Capabilities,
856) -> io::Result<(sys::Executable, PathTarget)> {
857    // Entry-based authorization: the parent is canonicalized but the final
858    // component stays lexical, so one granted multi-call entry (busybox,
859    // coreutils) does not authorize its siblings.
860    let bare = Path::new(command).components().count() == 1
861        && !command.contains('/')
862        && !command.contains('\\');
863    let candidates = if search && bare {
864        // An absent PATH under restricted authority must not become an oracle.
865        let path = path.ok_or_else(|| {
866            if caps.unrestricted_run() {
867                io::Error::new(
868                    io::ErrorKind::NotFound,
869                    "PATH is absent from the subprocess environment",
870                )
871            } else {
872                denied()
873            }
874        })?;
875        std::env::split_paths(path)
876            .flat_map(|directory| sys::expand(cwd.join(directory).join(command)))
877            .collect::<Vec<_>>()
878    } else {
879        let candidate = cwd.join(command);
880        vec![
881            sys::expand(candidate.clone())
882                .into_iter()
883                .find(|path| path.symlink_metadata().is_ok())
884                .unwrap_or(candidate),
885        ]
886    };
887    for candidate in candidates {
888        let target = match PathTarget::for_executable(&candidate) {
889            Ok(target) => target,
890            Err(error)
891                if search
892                    && bare
893                    && matches!(
894                        error.kind(),
895                        io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
896                    ) =>
897            {
898                continue;
899            }
900            Err(error) => {
901                let permitted = PathTarget::for_creation(&candidate)
902                    .ok()
903                    .is_some_and(|target| caps.check_run(&target).is_ok());
904                return Err(if permitted { error } else { denied() });
905            }
906        };
907        if !(search && bare) {
908            caps.check_run(&target).map_err(|_| denied())?;
909        }
910        let metadata = match std::fs::metadata(target.as_path()) {
911            Ok(metadata) => metadata,
912            Err(error)
913                if search
914                    && bare
915                    && matches!(
916                        error.kind(),
917                        io::ErrorKind::NotFound
918                            | io::ErrorKind::NotADirectory
919                            | io::ErrorKind::PermissionDenied
920                    ) =>
921            {
922                continue;
923            }
924            Err(error) => return Err(error),
925        };
926        let executable = sys::is_executable(&metadata);
927        if !executable && search && bare {
928            continue;
929        }
930        caps.check_run(&target).map_err(|_| denied())?;
931        #[cfg(target_os = "linux")]
932        let (executable, metadata) = {
933            let (directory, name) = avril_caps::fs::parent(target.as_path())?;
934            sys::Executable::open_at(&directory, &name)?
935        };
936        #[cfg(not(target_os = "linux"))]
937        let (executable, metadata) = sys::Executable::open(target.as_path())?;
938        if PathTarget::for_executable(&candidate)?.as_path() != target.as_path() {
939            return Err(io::Error::other("Executable changed during resolution"));
940        }
941        if !sys::is_executable(&metadata) {
942            return Err(io::Error::new(
943                io::ErrorKind::PermissionDenied,
944                "Not an executable file",
945            ));
946        }
947        return Ok((executable, target));
948    }
949    if caps.unrestricted_run() {
950        Err(io::Error::new(
951            io::ErrorKind::NotFound,
952            "Executable was not found",
953        ))
954    } else {
955        Err(denied())
956    }
957}