subc-os 0.1.9

Operating-system primitives for the subc daemon that need unsafe code, behind a safe API: per-process identity, signalling, memory and CPU usage, and the launch-nonce descriptor handoff.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! macOS responsibility isolation at exec. macOS charges privacy permissions
//! (Screen Recording, Accessibility, Files and Folders) to a process's
//! "responsible process", which is normally the program that launched it, so a
//! supervised module would otherwise borrow the daemon's grants. The daemon
//! therefore launches each module through a trampoline: `ck-subc` re-executed
//! with a hidden first argument. The trampoline calls `posix_spawnp` with
//! `POSIX_SPAWN_SETEXEC`, a Darwin flag that replaces the calling process's
//! image in place (like `execve`) instead of creating a child, after marking the
//! spawn as disclaiming responsibility. The module keeps the trampoline's pid,
//! process group, pipes and fd-3 launch-nonce descriptor, and becomes its own
//! responsible process.
//!
//! Use [`DisclaimedCommand`] for children of modules as well as supervisors. The
//! trampoline must be the caller's own binary and must handle the hidden mode
//! before starting threads or an async runtime. Call [`probe`] once at startup
//! and refuse to launch children if it fails. On non-macOS platforms the builder
//! launches the program directly, the trampoline path is unused, and both probe
//! and confirmation succeed immediately.
//!
//! A trampoline, rather than a `pre_exec` SETEXEC hook, is necessary because
//! after fork in a multithreaded process only async-signal-safe operations are
//! allowed. Constructing C strings, collecting the environment and setting up
//! `posix_spawn` allocate memory and can deadlock on a lock held at fork by
//! another thread. The fresh, single-threaded trampoline does that work safely;
//! the builder's only pre-exec work is allocation-free descriptor flag handling.
//!
//! ```no_run
//! use std::{process::Stdio, time::{Duration, Instant}};
//! use subc_os::privacy_identity::{self, DisclaimedCommand};
//!
//! fn main() -> Result<(), Box<dyn std::error::Error>> {
//!     let args: Vec<_> = std::env::args_os().skip(1).collect();
//!     privacy_identity::trampoline_main(&args); // First, before any runtime.
//!     let trampoline = std::env::current_exe()?;
//!     privacy_identity::probe(&trampoline, Instant::now() + Duration::from_secs(5))?;
//!     let mut builder = DisclaimedCommand::new(trampoline, "/bin/echo");
//!     builder.arg("hello").env("LANG", "C").stdout(Stdio::inherit());
//!     let (mut command, confirmation) = builder.into_command()?;
//!     let deadline = Instant::now() + Duration::from_secs(5);
//!     let mut child = command.spawn()?;
//!     drop(command); // Release the parent's ack writer before waiting for EOF.
//!     if let Err(error) = confirmation.confirm(deadline) {
//!         let _ = child.kill();
//!         let _ = child.wait();
//!         return Err(error.into());
//!     }
//!     child.wait()?;
//!     Ok(())
//! }
//! ```

mod command;
pub use command::{probe, ConfirmationError, DisclaimedCommand, ExecConfirmation, ExecError};

/// Reserved exit statuses from the trampoline, distinct from ordinary CLI errors.
pub fn failure_cause(code: Option<i32>) -> Option<&'static str> {
    match code {
        Some(120) => Some("responsibility_spawnattrs_setdisclaim is unavailable"),
        Some(121) => Some("responsibility_spawnattrs_setdisclaim failed"),
        Some(122) => Some("privacy identity posix_spawn failed"),
        Some(123) => Some("privacy identity trampoline setup failed"),
        _ => None,
    }
}

/// Capability response checked by embedding supervisors before using a binary.
pub const TRAMPOLINE_PROBE: &str = "subc-privacy-trampoline/v1";
/// A refused SETEXEC writes this tag and its cause before closing the ack pipe.
/// Empty EOF means exec succeeded, regardless of the module's eventual status.
pub const EXEC_REFUSAL_TAG: &str = "SUBC_PRIVACY_REFUSAL_V1 ";

/// Handle the hidden first argument before building any runtime. Returns only
/// for ordinary daemon arguments. Every hidden-mode error exits, never execs a
/// module with inherited responsibility.
pub fn trampoline_main(args: &[std::ffi::OsString]) {
    trampoline_entry(args, false);
}

#[cfg(feature = "test-support")]
pub fn trampoline_main_for_test(args: &[std::ffi::OsString]) {
    trampoline_entry(args, true);
}

fn trampoline_entry(args: &[std::ffi::OsString], test_hooks: bool) {
    if !args.first().is_some_and(|arg| arg == "__disclaim-exec") {
        return;
    }
    #[cfg(target_os = "macos")]
    {
        if args.len() == 2 && args[1] == "--probe" {
            #[cfg(feature = "test-support")]
            let missing =
                test_hooks && std::env::var_os("SUBC_TEST_PRIVACY_MISSING_SYMBOL").is_some();
            #[cfg(not(feature = "test-support"))]
            let missing = false;
            match macos::resolve_disclaim(missing) {
                Ok(_) => {
                    println!("{TRAMPOLINE_PROBE}");
                    std::process::exit(0);
                }
                Err(error) => {
                    eprintln!("ck-subc: own privacy identity refused: {error}");
                    std::process::exit(error.exit_code());
                }
            }
        }
        #[cfg(feature = "test-support")]
        if test_hooks {
            let error = macos::disclaim_exec_for_test(&args[1..])
                .expect_err("SETEXEC cannot return on success");
            eprintln!("ck-subc: own privacy identity refused: {error}");
            std::process::exit(error.exit_code());
        }
        let _ = test_hooks;
        let error = macos::disclaim_exec(&args[1..]).expect_err("SETEXEC cannot return on success");
        eprintln!("ck-subc: own privacy identity refused: {error}");
        std::process::exit(error.exit_code());
    }
    #[cfg(not(target_os = "macos"))]
    {
        let _ = test_hooks;
        eprintln!("ck-subc: own privacy identity is only available on macOS");
        std::process::exit(123);
    }
}

#[cfg(target_os = "macos")]
pub use macos::{disclaim_exec, ExecAcknowledgement};

#[cfg(all(target_os = "macos", feature = "test-support"))]
pub use macos::{disclaim_exec_for_test, privacy_observation_for_test};

#[cfg(target_os = "macos")]
mod macos {
    use super::ExecError;
    use std::{
        ffi::{CString, OsString},
        io,
        os::{
            fd::{AsRawFd, FromRawFd, OwnedFd},
            unix::ffi::OsStrExt,
        },
    };

    /// A second pipe, independent of the fd-3 launch-nonce pipe. The trampoline
    /// marks its write end close-on-exec, so EOF means the trampoline either
    /// replaced itself with the module or exited.
    pub struct ExecAcknowledgement {
        writer: OwnedFd,
    }

    impl ExecAcknowledgement {
        pub fn pipe() -> io::Result<(std::io::PipeReader, Self)> {
            let (reader, writer) = io::pipe()?;
            // SAFETY: nonblocking reads let the daemon await EOF with AsyncFd
            // rather than block a runtime worker while the trampoline starts.
            #[allow(unsafe_code)]
            unsafe {
                let flags = libc::fcntl(reader.as_raw_fd(), libc::F_GETFL);
                if flags == -1
                    || libc::fcntl(reader.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK)
                        == -1
                {
                    return Err(io::Error::last_os_error());
                }
            }
            // SAFETY: duplicates the owned descriptor to number 4 or higher, so it
            // cannot collide with stdio (0-2) or the launch-nonce descriptor the
            // child receives as fd 3. The parent copy stays CLOEXEC; only our
            // child clears that flag.
            #[allow(unsafe_code)]
            let fd = unsafe { libc::fcntl(writer.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 4) };
            if fd == -1 {
                return Err(io::Error::last_os_error());
            }
            // SAFETY: successful fcntl returned a fresh descriptor owned here.
            #[allow(unsafe_code)]
            let writer = unsafe { OwnedFd::from_raw_fd(fd) };
            Ok((reader, Self { writer }))
        }

        pub fn fd(&self) -> i32 {
            self.writer.as_raw_fd()
        }

        pub(super) fn into_writer(self) -> OwnedFd {
            self.writer
        }

        /// Register before the final nonce handoff. Retain `self` until spawn
        /// returns, then drop it and the Command so the parent holds no writer.
        pub fn install(&self, command: &mut std::process::Command) {
            use std::os::unix::process::CommandExt;
            let fd = self.fd();
            // SAFETY: pre_exec makes only fcntl calls, allocates nothing, and
            // touches no Rust locks. The captured descriptor is kept alive by
            // the caller until spawn completes and is never fd 3 or stdio.
            #[allow(unsafe_code)]
            unsafe {
                command.pre_exec(move || inherit_writer(fd));
            }
        }

        /// Keep the descriptor alive in the command, even if confirmation is
        /// dropped first. Dropping the command after spawn releases the parent's
        /// writer so the reader can observe EOF.
        pub(super) fn install_owned(self, command: &mut std::process::Command) {
            use std::os::unix::process::CommandExt;
            // SAFETY: the closure owns the descriptor and uses only fcntl. It
            // cannot outlive its descriptor or acquire a Rust lock after fork.
            #[allow(unsafe_code)]
            unsafe {
                command.pre_exec(move || inherit_writer(self.fd()));
            }
        }
    }

    fn inherit_writer(fd: i32) -> io::Result<()> {
        // SAFETY: the installing hook retains the descriptor through spawn.
        // Only async-signal-safe fcntl calls execute here, with no allocation.
        #[allow(unsafe_code)]
        unsafe {
            let flags = libc::fcntl(fd, libc::F_GETFD);
            if flags == -1 || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 {
                return Err(io::Error::last_os_error());
            }
        }
        Ok(())
    }

    /// Execute `<ack fd> <program> <args...>` as a new responsible process.
    /// Success never returns. This must run in a freshly exec'd, single-threaded
    /// trampoline, NOT in pre_exec: CString and environment construction allocate.
    pub fn disclaim_exec(args: &[OsString]) -> Result<(), ExecError> {
        let result = exec(args, false);
        if let Err(error) = &result {
            write_refusal_record(args, error);
        }
        result
    }

    /// Only the dedicated fixture binary calls this. No production config or
    /// environment variable can select another lookup symbol in `ck-subc`.
    #[cfg(feature = "test-support")]
    pub fn disclaim_exec_for_test(args: &[OsString]) -> Result<(), ExecError> {
        if std::env::var_os("SUBC_TEST_PRIVACY_CLOSE_ACK_WITHOUT_EXEC").is_some() {
            let fd: i32 = args[0].to_str().unwrap().parse().unwrap();
            // SAFETY: the fixture was handed this descriptor by its supervisor;
            // closing it simulates a premature exec acknowledgement, not exec.
            #[allow(unsafe_code)]
            unsafe {
                libc::close(fd);
            }
            std::thread::sleep(std::time::Duration::from_secs(6));
            return Err(ExecError::new(123, "test trampoline did not exec"));
        }
        if let Ok(ms) = std::env::var("SUBC_TEST_PRIVACY_EXEC_DELAY_MS") {
            std::thread::sleep(std::time::Duration::from_millis(ms.parse().unwrap()));
        }
        let result = exec(
            args,
            std::env::var_os("SUBC_TEST_PRIVACY_MISSING_SYMBOL").is_some(),
        );
        if let Err(error) = &result {
            write_refusal_record(args, error);
        }
        result
    }

    fn write_refusal_record(args: &[OsString], error: &ExecError) {
        let Some(fd) = args
            .first()
            .and_then(|s| s.to_str())
            .and_then(|s| s.parse::<i32>().ok())
            .filter(|fd| *fd >= 4)
        else {
            return;
        };
        let record = format!("{}{error}\n", super::EXEC_REFUSAL_TAG);
        let bytes = record.as_bytes();
        let mut written = 0;
        while written < bytes.len() {
            // SAFETY: write borrows a live byte buffer and retains no pointer.
            // The descriptor is the inherited ack pipe, never nonce or stdio.
            // This runs after exec in a single-threaded trampoline, not pre_exec.
            #[allow(unsafe_code)]
            let count =
                unsafe { libc::write(fd, bytes[written..].as_ptr().cast(), bytes.len() - written) };
            if count > 0 {
                written += count as usize;
            } else if count == -1 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted
            {
                continue;
            } else {
                break;
            }
        }
    }

    type Disclaim = unsafe extern "C" fn(*mut libc::posix_spawnattr_t, libc::c_int) -> libc::c_int;

    pub(super) fn resolve_disclaim(missing_symbol: bool) -> Result<Disclaim, ExecError> {
        let symbol = if missing_symbol {
            c"__subc_test_missing_responsibility_symbol"
        } else {
            c"responsibility_spawnattrs_setdisclaim"
        };
        // SAFETY: RTLD_DEFAULT resolves a fixed NUL-terminated name. The private
        // Darwin ABI is int(posix_spawnattr_t *, int); the function stays loaded
        // for this process's lifetime because it is part of libSystem.
        #[allow(unsafe_code)]
        unsafe {
            let address = libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr());
            if address.is_null() {
                return Err(ExecError::new(120, "module was not executed"));
            }
            Ok(std::mem::transmute::<*mut libc::c_void, Disclaim>(address))
        }
    }

    /// The private ABI is confined here. Darwin's SETEXEC preserves the pid,
    /// group and every non-CLOEXEC descriptor; CLOEXEC_DEFAULT must NOT be used.
    /// Every return is an error: there is deliberately no plain-exec fallback.
    fn exec(args: &[OsString], missing_symbol: bool) -> Result<(), ExecError> {
        let disclaim = resolve_disclaim(missing_symbol)?;
        let fd: i32 = args
            .first()
            .and_then(|s| s.to_str())
            .and_then(|s| s.parse().ok())
            .filter(|fd| *fd >= 4)
            .ok_or_else(|| ExecError::new(123, "missing exec acknowledgement descriptor"))?;
        if args.len() < 2 {
            return Err(ExecError::new(123, "missing module program"));
        }
        let strings: Vec<CString> = args[1..]
            .iter()
            .map(|s| CString::new(s.as_bytes()))
            .collect::<Result<_, _>>()
            .map_err(|e| ExecError::new(123, e))?;
        let mut argv: Vec<*mut libc::c_char> =
            strings.iter().map(|s| s.as_ptr().cast_mut()).collect();
        argv.push(std::ptr::null_mut());
        let environment: Vec<CString> = std::env::vars_os()
            .map(|(key, value)| {
                let mut bytes = key.as_bytes().to_vec();
                bytes.push(b'=');
                bytes.extend_from_slice(value.as_bytes());
                CString::new(bytes)
            })
            .collect::<Result<_, _>>()
            .map_err(|e| ExecError::new(123, e))?;
        let mut envp: Vec<*mut libc::c_char> =
            environment.iter().map(|s| s.as_ptr().cast_mut()).collect();
        envp.push(std::ptr::null_mut());
        // SAFETY: RTLD_DEFAULT resolves a fixed, NUL-terminated function name.
        // Darwin's ABI is int(posix_spawnattr_t *, int). All argument/env arrays
        // are NUL-terminated and their backing CStrings live through spawn. The
        // initialized attr is destroyed on every returning path. SETEXEC with
        // no file actions retains stdio, fd 3 and the process group in place.
        #[allow(unsafe_code)]
        unsafe {
            // The acknowledgement survives the first exec but closes on SETEXEC.
            let flags = libc::fcntl(fd, libc::F_GETFD);
            if flags == -1 || libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) == -1 {
                return Err(ExecError::new(123, io::Error::last_os_error()));
            }
            let mut attr = std::mem::MaybeUninit::<libc::posix_spawnattr_t>::uninit();
            let result = libc::posix_spawnattr_init(attr.as_mut_ptr());
            if result != 0 {
                return Err(ExecError::new(123, io::Error::from_raw_os_error(result)));
            }
            let mut attr = attr.assume_init();
            let result = libc::posix_spawnattr_setflags(
                &mut attr,
                libc::POSIX_SPAWN_SETEXEC as libc::c_short,
            );
            let (code, result) = if result != 0 {
                (123, result)
            } else {
                let result = disclaim(&mut attr, 1);
                if result != 0 {
                    (121, result)
                } else {
                    let mut pid = 0;
                    (
                        122,
                        libc::posix_spawnp(
                            &mut pid,
                            strings[0].as_ptr(),
                            std::ptr::null(),
                            &attr,
                            argv.as_ptr(),
                            envp.as_ptr(),
                        ),
                    )
                }
            };
            libc::posix_spawnattr_destroy(&mut attr);
            Err(ExecError::new(
                code,
                if result == 0 {
                    io::Error::other("SETEXEC unexpectedly returned")
                } else {
                    io::Error::from_raw_os_error(result)
                },
            ))
        }
    }

    /// Real-process observation used only by the permission-isolation fixture.
    #[cfg(feature = "test-support")]
    pub fn privacy_observation_for_test() -> io::Result<(u32, u32, u32, u32)> {
        // SAFETY: resolve Darwin's int(pid_t) probe with a fixed name, then pass
        // this process's and its parent's pids. No pointers are retained by the
        // function. getpgrp is an independent kernel observation of containment.
        #[allow(unsafe_code)]
        unsafe {
            let address = libc::dlsym(
                libc::RTLD_DEFAULT,
                c"responsibility_get_pid_responsible_for_pid".as_ptr(),
            );
            if address.is_null() {
                return Err(io::Error::other("responsibility probe unavailable"));
            }
            let probe: unsafe extern "C" fn(libc::pid_t) -> libc::pid_t =
                std::mem::transmute(address);
            Ok((
                probe(libc::getpid()) as u32,
                probe(libc::getppid()) as u32,
                libc::getppid() as u32,
                libc::getpgrp() as u32,
            ))
        }
    }
}