Skip to content

Commit b0aa7db

Browse files
committed
cmdext: Add pass_systemd_fds() for socket activation fd passing
It's a bit too complicated to use the systemd socket protocol, let's make it simpler. Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 5493d68 commit b0aa7db

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

src/cmdext.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@
44
//!
55
//! - File descriptor passing
66
//! - Changing to a file-descriptor relative directory
7+
//! - Systemd socket activation fd passing
78
89
use cap_std::fs::Dir;
910
use cap_std::io_lifetimes;
1011
use cap_tempfile::cap_std;
1112
use io_lifetimes::OwnedFd;
1213
use rustix::fd::{AsFd, FromRawFd, IntoRawFd};
1314
use rustix::io::FdFlags;
15+
use std::ffi::CString;
1416
use std::os::fd::AsRawFd;
1517
use std::os::unix::process::CommandExt;
1618
use std::sync::Arc;
1719

20+
/// The file descriptor number at which systemd passes the first socket.
21+
/// See `sd_listen_fds(3)`.
22+
const SD_LISTEN_FDS_START: i32 = 3;
23+
1824
/// Extension trait for [`std::process::Command`].
1925
///
2026
/// [`cap_std::fs::Dir`]: https://docs.rs/cap-std/latest/cap_std/fs/struct.Dir.html
@@ -25,6 +31,24 @@ pub trait CapStdExtCommandExt {
2531
/// Use the given directory as the current working directory for the process.
2632
fn cwd_dir(&mut self, dir: Dir) -> &mut Self;
2733

34+
/// Set up the [systemd socket activation][sd_listen_fds] environment for
35+
/// the child process.
36+
///
37+
/// Each `(fd, name)` pair is placed at consecutive file descriptor numbers
38+
/// starting from `SD_LISTEN_FDS_START` (3), and the environment variables
39+
/// `LISTEN_PID`, `LISTEN_FDS`, and `LISTEN_FDNAMES` are set accordingly
40+
/// inside the child (via `pre_exec`).
41+
///
42+
/// Note that `LISTEN_PID` must reflect the child's actual PID, so it
43+
/// cannot be set with [`Command::env`] — it is written directly with
44+
/// `setenv(3)` in the forked child before exec.
45+
///
46+
/// [sd_listen_fds]: https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html
47+
fn pass_systemd_fds<'a>(
48+
&mut self,
49+
fds: impl IntoIterator<Item = (Arc<OwnedFd>, &'a str)>,
50+
) -> &mut Self;
51+
2852
/// On Linux, arrange for [`SIGTERM`] to be delivered to the child if the
2953
/// parent *thread* exits. This helps avoid leaking child processes if
3054
/// the parent crashes for example.
@@ -39,6 +63,22 @@ pub trait CapStdExtCommandExt {
3963
fn lifecycle_bind_to_parent_thread(&mut self) -> &mut Self;
4064
}
4165

66+
/// Wrapper around `libc::setenv` that checks the return value.
67+
///
68+
/// # Safety
69+
///
70+
/// Must only be called in a single-threaded context (e.g. after `fork()`
71+
/// and before `exec()`).
72+
#[allow(unsafe_code)]
73+
unsafe fn check_setenv(key: *const i8, val: *const i8) -> std::io::Result<()> {
74+
// SAFETY: Caller guarantees we are in a single-threaded context
75+
// with valid nul-terminated C strings.
76+
if unsafe { libc::setenv(key, val, 1) } != 0 {
77+
return Err(std::io::Error::last_os_error());
78+
}
79+
Ok(())
80+
}
81+
4282
#[allow(unsafe_code)]
4383
impl CapStdExtCommandExt for std::process::Command {
4484
fn take_fd_n(&mut self, fd: Arc<OwnedFd>, target: i32) -> &mut Self {
@@ -62,6 +102,49 @@ impl CapStdExtCommandExt for std::process::Command {
62102
self
63103
}
64104

105+
fn pass_systemd_fds<'a>(
106+
&mut self,
107+
fds: impl IntoIterator<Item = (Arc<OwnedFd>, &'a str)>,
108+
) -> &mut Self {
109+
let mut n_fds: i32 = 0;
110+
let mut names = Vec::new();
111+
for (fd, name) in fds {
112+
assert!(
113+
!name.contains('\0') && !name.contains(':'),
114+
"systemd fd name must not contain NUL or ':'"
115+
);
116+
let target = SD_LISTEN_FDS_START
117+
.checked_add(n_fds)
118+
.expect("too many fds");
119+
self.take_fd_n(fd, target);
120+
names.push(name.to_owned());
121+
n_fds = n_fds.checked_add(1).expect("too many fds");
122+
}
123+
124+
// Build the env values now (owned), then move them into the pre_exec
125+
// closure. We cannot use Command::env() because it causes Rust's
126+
// Command to build an envp array that replaces environ, which would
127+
// clobber the setenv calls we make in pre_exec for LISTEN_PID.
128+
let fd_count = CString::new(n_fds.to_string()).unwrap();
129+
// SAFETY: We validated that no name contains NUL above.
130+
let fd_names = CString::new(names.join(":")).unwrap();
131+
132+
unsafe {
133+
self.pre_exec(move || {
134+
let pid = rustix::process::getpid();
135+
let pid_dec = rustix::path::DecInt::new(pid.as_raw_nonzero().get());
136+
// SAFETY: After fork() and before exec(), the child is
137+
// single-threaded, so setenv (which is not thread-safe) is
138+
// safe to call here.
139+
check_setenv(c"LISTEN_PID".as_ptr(), pid_dec.as_c_str().as_ptr())?;
140+
check_setenv(c"LISTEN_FDS".as_ptr(), fd_count.as_ptr())?;
141+
check_setenv(c"LISTEN_FDNAMES".as_ptr(), fd_names.as_ptr())?;
142+
Ok(())
143+
});
144+
}
145+
self
146+
}
147+
65148
fn cwd_dir(&mut self, dir: Dir) -> &mut Self {
66149
unsafe {
67150
self.pre_exec(move || {

tests/it/main.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,3 +860,78 @@ fn test_lifecycle_bind_to_parent_thread() -> Result<()> {
860860

861861
Ok(())
862862
}
863+
864+
#[test]
865+
#[cfg(not(windows))]
866+
fn test_pass_systemd_fds() -> Result<()> {
867+
// Verify the child sees the correct LISTEN_* env vars and can read from the fd.
868+
let (r, w) = rustix::pipe::pipe()?;
869+
let r = Arc::new(r);
870+
let mut w: cap_std::fs::File = w.into();
871+
write!(w, "sd-activate-test")?;
872+
drop(w);
873+
874+
// The child: verify LISTEN_PID matches $$, print the other vars, read fd 3.
875+
let script = r#"
876+
test "$LISTEN_PID" = "$$" || { echo "LISTEN_PID=$LISTEN_PID but $$=$$" >&2; exit 1; }
877+
printf '%s\n' "$LISTEN_FDS" "$LISTEN_FDNAMES"
878+
cat <&3
879+
"#;
880+
let mut c = Command::new("/bin/bash");
881+
c.arg("-c").arg(script);
882+
c.stdout(std::process::Stdio::piped());
883+
c.pass_systemd_fds([(r, "myproto")]);
884+
let out = c.output()?;
885+
assert!(
886+
out.status.success(),
887+
"child failed: {}",
888+
String::from_utf8_lossy(&out.stderr)
889+
);
890+
let stdout = String::from_utf8_lossy(&out.stdout);
891+
let lines: Vec<&str> = stdout.lines().collect();
892+
assert_eq!(lines.len(), 3, "unexpected output: {stdout}");
893+
assert_eq!(lines[0], "1");
894+
assert_eq!(lines[1], "myproto");
895+
assert_eq!(lines[2], "sd-activate-test");
896+
897+
Ok(())
898+
}
899+
900+
#[test]
901+
#[cfg(not(windows))]
902+
fn test_pass_systemd_fds_multi() -> Result<()> {
903+
// Test passing multiple fds with distinct names.
904+
let (r1, w1) = rustix::pipe::pipe()?;
905+
let (r2, w2) = rustix::pipe::pipe()?;
906+
let r1 = Arc::new(r1);
907+
let r2 = Arc::new(r2);
908+
let mut w1: cap_std::fs::File = w1.into();
909+
let mut w2: cap_std::fs::File = w2.into();
910+
write!(w1, "first")?;
911+
write!(w2, "second")?;
912+
drop(w1);
913+
drop(w2);
914+
915+
// fd 3 = first pipe, fd 4 = second pipe
916+
let script = r#"
917+
printf '%s\n' "$LISTEN_FDS" "$LISTEN_FDNAMES"
918+
cat <&3
919+
printf '\n'
920+
cat <&4
921+
"#;
922+
let mut c = Command::new("/bin/bash");
923+
c.arg("-c").arg(script);
924+
c.stdout(std::process::Stdio::piped());
925+
c.pass_systemd_fds([(r1, "alpha"), (r2, "beta")]);
926+
let out = c.output()?;
927+
assert!(out.status.success(), "child failed: {:?}", out);
928+
let stdout = String::from_utf8_lossy(&out.stdout);
929+
let lines: Vec<&str> = stdout.lines().collect();
930+
assert_eq!(lines.len(), 4, "unexpected output: {stdout}");
931+
assert_eq!(lines[0], "2");
932+
assert_eq!(lines[1], "alpha:beta");
933+
assert_eq!(lines[2], "first");
934+
assert_eq!(lines[3], "second");
935+
936+
Ok(())
937+
}

0 commit comments

Comments
 (0)