Skip to main content

subc_os/privacy_identity/
command.rs

1use std::{
2    ffi::{OsStr, OsString},
3    fmt, io,
4    path::{Path, PathBuf},
5    process::{Command, Stdio},
6    time::Instant,
7};
8
9/// A named refusal from the privacy trampoline, using its reserved exit status.
10#[derive(Debug)]
11pub struct ExecError {
12    code: i32,
13    detail: String,
14}
15
16impl ExecError {
17    pub fn exit_code(&self) -> i32 {
18        self.code
19    }
20
21    #[cfg(target_os = "macos")]
22    pub(super) fn new(code: i32, detail: impl fmt::Display) -> Self {
23        Self {
24            code,
25            detail: detail.to_string(),
26        }
27    }
28}
29
30impl fmt::Display for ExecError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(
33            f,
34            "{}: {}",
35            super::failure_cause(Some(self.code)).unwrap_or("privacy identity failure"),
36            self.detail
37        )
38    }
39}
40
41impl std::error::Error for ExecError {}
42
43/// Failure to confirm exec or to validate a trampoline's capability response.
44#[derive(Debug)]
45pub enum ConfirmationError {
46    /// The trampoline refused exec with a named cause.
47    Refused(ExecError),
48    /// The caller's deadline elapsed without confirmation. Never success.
49    TimedOut,
50    /// An IO failure or an invalid protocol response.
51    Io(io::Error),
52}
53
54impl fmt::Display for ConfirmationError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::Refused(error) => error.fmt(f),
58            Self::TimedOut => f.write_str("privacy identity confirmation timed out"),
59            Self::Io(error) => write!(f, "privacy identity acknowledgement failed: {error}"),
60        }
61    }
62}
63
64impl std::error::Error for ConfirmationError {
65    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
66        match self {
67            Self::Refused(error) => Some(error),
68            Self::Io(error) => Some(error),
69            Self::TimedOut => None,
70        }
71    }
72}
73
74impl From<io::Error> for ConfirmationError {
75    fn from(error: io::Error) -> Self {
76        Self::Io(error)
77    }
78}
79
80/// Build a child command with its own macOS responsible-process identity.
81///
82/// `trampoline` is the caller's binary, whose main must call
83/// [`super::trampoline_main`] first. Validate it once with [`probe`] at startup.
84/// No plain-exec fallback is ever used on macOS. Elsewhere this is a plain
85/// program command and `trampoline` is ignored.
86pub struct DisclaimedCommand {
87    command: Command,
88    #[cfg(target_os = "macos")]
89    program: OsString,
90    #[cfg(target_os = "macos")]
91    args: Vec<OsString>,
92}
93
94impl DisclaimedCommand {
95    pub fn new(trampoline: impl Into<PathBuf>, program: impl Into<OsString>) -> Self {
96        let trampoline = trampoline.into();
97        let program = program.into();
98        #[cfg(target_os = "macos")]
99        {
100            Self {
101                command: Command::new(trampoline),
102                program,
103                args: Vec::new(),
104            }
105        }
106        #[cfg(not(target_os = "macos"))]
107        {
108            let _ = trampoline;
109            Self {
110                command: Command::new(program),
111            }
112        }
113    }
114
115    pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
116        #[cfg(target_os = "macos")]
117        self.args.push(arg.as_ref().to_owned());
118        #[cfg(not(target_os = "macos"))]
119        self.command.arg(arg);
120        self
121    }
122
123    pub fn args<I, S>(&mut self, args: I) -> &mut Self
124    where
125        I: IntoIterator<Item = S>,
126        S: AsRef<OsStr>,
127    {
128        for arg in args {
129            self.arg(arg);
130        }
131        self
132    }
133
134    pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
135        self.command.env(key, value);
136        self
137    }
138
139    pub fn env_clear(&mut self) -> &mut Self {
140        self.command.env_clear();
141        self
142    }
143
144    pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
145        self.command.env_remove(key);
146        self
147    }
148
149    pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
150        self.command.current_dir(dir);
151        self
152    }
153
154    pub fn stdin(&mut self, stdio: impl Into<Stdio>) -> &mut Self {
155        self.command.stdin(stdio);
156        self
157    }
158
159    pub fn stdout(&mut self, stdio: impl Into<Stdio>) -> &mut Self {
160        self.command.stdout(stdio);
161        self
162    }
163
164    pub fn stderr(&mut self, stdio: impl Into<Stdio>) -> &mut Self {
165        self.command.stderr(stdio);
166        self
167    }
168
169    /// Produce a command and its single-use exec confirmation.
170    ///
171    /// After spawning, **drop the command before confirming**: it owns the
172    /// parent's acknowledgement writer. Dropping confirmation before spawn is
173    /// safe, but loses the ability to detect a refusal. Do not alter the command's
174    /// trampoline arguments. A spawn error is an ordinary IO error; a later
175    /// confirmation error requires the caller to kill and reap the child.
176    pub fn into_command(self) -> io::Result<(Command, ExecConfirmation)> {
177        #[cfg(target_os = "macos")]
178        {
179            let mut command = self.command;
180            let (reader, ack) = super::ExecAcknowledgement::pipe()?;
181            command
182                .arg("__disclaim-exec")
183                .arg(ack.fd().to_string())
184                .arg(self.program)
185                .args(self.args);
186            ack.install_owned(&mut command);
187            Ok((command, ExecConfirmation { reader }))
188        }
189        #[cfg(not(target_os = "macos"))]
190        {
191            Ok((self.command, ExecConfirmation {}))
192        }
193    }
194
195    /// Tokio conversion with the same writer ownership as [`Self::into_command`].
196    /// Confirmation is blocking: use a blocking task when inside an async runtime.
197    #[cfg(feature = "tokio")]
198    pub fn into_tokio_command(self) -> io::Result<(tokio::process::Command, ExecConfirmation)> {
199        let (command, confirmation) = self.into_command()?;
200        Ok((command.into(), confirmation))
201    }
202}
203
204/// The existing exec-ack pipe's reader, not a child-exit or readiness check.
205///
206/// Only a cooperating trampoline's empty EOF confirms exec. The child may have
207/// already exited, even with a reserved trampoline status. On non-macOS targets
208/// confirmation is a no-op and succeeds immediately, including past deadlines.
209#[derive(Debug)]
210pub struct ExecConfirmation {
211    #[cfg(target_os = "macos")]
212    reader: std::io::PipeReader,
213}
214
215impl ExecConfirmation {
216    /// Block until empty EOF, a tagged refusal, an IO error, or `deadline`.
217    /// Drop the originating command before calling; kill/reap on error.
218    pub fn confirm(self, deadline: Instant) -> Result<(), ConfirmationError> {
219        #[cfg(target_os = "macos")]
220        {
221            let record = macos::read_until_eof(self.reader, deadline, 1024)?;
222            if record.is_empty() {
223                return Ok(());
224            }
225            let cause = std::str::from_utf8(&record)
226                .ok()
227                .and_then(|record| record.trim().strip_prefix(super::EXEC_REFUSAL_TAG))
228                .ok_or_else(|| macos::invalid_response("invalid privacy exec refusal record"))?;
229            for code in 120..=123 {
230                let name = super::failure_cause(Some(code)).expect("reserved status");
231                if let Some(detail) = cause.strip_prefix(name).and_then(|s| s.strip_prefix(": ")) {
232                    return Err(ConfirmationError::Refused(ExecError::new(code, detail)));
233                }
234            }
235            Err(macos::invalid_response(
236                "unknown privacy exec refusal cause",
237            ))
238        }
239        #[cfg(not(target_os = "macos"))]
240        {
241            let _ = deadline;
242            Ok(())
243        }
244    }
245}
246
247/// Validate the existing `__disclaim-exec --probe` response once at startup.
248///
249/// On macOS a bad path, failed lookup, wrong response or timeout is an error:
250/// callers must refuse child launches, never fall back to inherited grants.
251/// The probe uses the parent's environment, not a future child's overrides.
252/// Other platforms do not execute the trampoline and immediately return `Ok`.
253pub fn probe(trampoline: impl AsRef<Path>, deadline: Instant) -> Result<(), ConfirmationError> {
254    #[cfg(target_os = "macos")]
255    {
256        macos::probe(trampoline.as_ref(), deadline)
257    }
258    #[cfg(not(target_os = "macos"))]
259    {
260        let _ = (trampoline, deadline);
261        Ok(())
262    }
263}
264
265#[cfg(all(test, target_os = "macos"))]
266mod tests {
267    use super::*;
268    use std::{io::Write, time::Duration};
269
270    #[test]
271    fn malformed_acknowledgements_are_not_exec_confirmation() {
272        for record in [
273            b"not a tagged refusal\n".as_slice(),
274            b"SUBC_PRIVACY_REFUSAL_V1 unknown cause\n".as_slice(),
275            &[b'x'; 1025],
276        ] {
277            let (reader, ack) = super::super::ExecAcknowledgement::pipe().unwrap();
278            let mut writer = std::fs::File::from(ack.into_writer());
279            writer.write_all(record).unwrap();
280            drop(writer);
281            let result =
282                ExecConfirmation { reader }.confirm(Instant::now() + Duration::from_secs(5));
283            assert!(
284                matches!(result, Err(ConfirmationError::Io(_))),
285                "{result:?}"
286            );
287        }
288    }
289
290    #[test]
291    fn partial_refusal_without_eof_still_obeys_deadline() {
292        let (reader, ack) = super::super::ExecAcknowledgement::pipe().unwrap();
293        let mut writer = std::fs::File::from(ack.into_writer());
294        writer.write_all(b"SUBC_PRIVACY_REFUSAL_V1 ").unwrap();
295        let result =
296            ExecConfirmation { reader }.confirm(Instant::now() + Duration::from_millis(10));
297        assert!(
298            matches!(result, Err(ConfirmationError::TimedOut)),
299            "{result:?}"
300        );
301        drop(writer);
302    }
303}
304
305#[cfg(target_os = "macos")]
306mod macos {
307    use super::*;
308    use std::{io::Read, os::fd::AsRawFd, time::Duration};
309
310    pub(super) fn invalid_response(detail: &str) -> ConfirmationError {
311        io::Error::new(io::ErrorKind::InvalidData, detail).into()
312    }
313
314    /// The reader is nonblocking, so the entire handshake obeys one deadline,
315    /// even if a bad trampoline writes a partial record and holds the pipe open.
316    pub(super) fn read_until_eof(
317        mut reader: io::PipeReader,
318        deadline: Instant,
319        limit: usize,
320    ) -> Result<Vec<u8>, ConfirmationError> {
321        let mut record = Vec::new();
322        loop {
323            let remaining = deadline
324                .checked_duration_since(Instant::now())
325                .filter(|remaining| !remaining.is_zero())
326                .ok_or(ConfirmationError::TimedOut)?;
327            // Round up so poll cannot expire before a sub-millisecond deadline.
328            let millis =
329                remaining.as_millis() + u128::from(remaining.subsec_nanos() % 1_000_000 != 0);
330            let mut pollfd = libc::pollfd {
331                fd: reader.as_raw_fd(),
332                events: libc::POLLIN,
333                revents: 0,
334            };
335            // SAFETY: one live pipe descriptor and a stack-owned pollfd; poll
336            // retains no pointers. The bounded timeout cannot overflow c_int.
337            #[allow(unsafe_code)]
338            let ready = unsafe { libc::poll(&mut pollfd, 1, millis.min(i32::MAX as u128) as i32) };
339            if ready < 0 {
340                let error = io::Error::last_os_error();
341                if error.kind() == io::ErrorKind::Interrupted {
342                    continue;
343                }
344                return Err(error.into());
345            }
346            if ready == 0 {
347                continue; // Recheck the absolute deadline, including long waits.
348            }
349            let mut buffer = [0u8; 256];
350            match reader.read(&mut buffer) {
351                Ok(0) => return Ok(record),
352                Ok(count) if record.len() + count <= limit => {
353                    record.extend_from_slice(&buffer[..count]);
354                }
355                Ok(_) => return Err(invalid_response("privacy acknowledgement is too long")),
356                Err(error)
357                    if matches!(
358                        error.kind(),
359                        io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock
360                    ) => {}
361                Err(error) => return Err(error.into()),
362            }
363        }
364    }
365
366    pub(super) fn probe(trampoline: &Path, deadline: Instant) -> Result<(), ConfirmationError> {
367        if Instant::now() >= deadline {
368            return Err(ConfirmationError::TimedOut);
369        }
370        // Reuse the pipe's nonblocking reader. The extra writer duplication is
371        // harmless here; unlike exec acknowledgement stdout needs no pre-exec hook.
372        let (reader, writer) = super::super::ExecAcknowledgement::pipe()?;
373        use std::os::fd::OwnedFd;
374        let stdout: OwnedFd = writer.into_writer();
375        let mut command = Command::new(trampoline);
376        command
377            .args(["__disclaim-exec", "--probe"])
378            .stdin(Stdio::null())
379            .stdout(Stdio::from(stdout))
380            .stderr(Stdio::null());
381        let mut child = command.spawn()?;
382        drop(command);
383        let result = (|| {
384            let answer = read_until_eof(reader, deadline, 256)?;
385            let status = loop {
386                if let Some(status) = child.try_wait()? {
387                    break status;
388                }
389                let remaining = deadline
390                    .checked_duration_since(Instant::now())
391                    .filter(|remaining| !remaining.is_zero())
392                    .ok_or(ConfirmationError::TimedOut)?;
393                std::thread::sleep(remaining.min(Duration::from_millis(5)));
394            };
395            if !status.success() {
396                if let Some(code) = status
397                    .code()
398                    .filter(|code| super::super::failure_cause(Some(*code)).is_some())
399                {
400                    return Err(ConfirmationError::Refused(ExecError::new(
401                        code,
402                        "trampoline probe refused",
403                    )));
404                }
405                return Err(invalid_response(
406                    "privacy trampoline probe exited unsuccessfully",
407                ));
408            }
409            if std::str::from_utf8(&answer)
410                .is_ok_and(|s| s.trim() == super::super::TRAMPOLINE_PROBE)
411            {
412                Ok(())
413            } else {
414                Err(invalid_response(
415                    "binary does not implement the privacy trampoline protocol",
416                ))
417            }
418        })();
419        if result.is_err() {
420            let _ = child.kill();
421            let _ = child.wait();
422        }
423        result
424    }
425}