From f39ba10b4830fc50b9cbe22d9f036a90c6bf7a45 Mon Sep 17 00:00:00 2001 From: Andrew Walbran Date: Fri, 10 Apr 2026 14:05:08 +0100 Subject: [PATCH 1/5] Move inherited_fds module from rustutils library. --- src/inherited.rs | 276 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 277 insertions(+) create mode 100644 src/inherited.rs diff --git a/src/inherited.rs b/src/inherited.rs new file mode 100644 index 0000000..cb91b5a --- /dev/null +++ b/src/inherited.rs @@ -0,0 +1,276 @@ +// Copyright 2024, The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Library for safely obtaining `OwnedFd` for inherited file descriptors. + +use nix::fcntl::{F_SETFD, FdFlag, fcntl}; +use nix::libc; +use std::collections::HashMap; +use std::fs::canonicalize; +use std::fs::read_dir; +use std::os::fd::FromRawFd; +use std::os::fd::OwnedFd; +use std::os::fd::RawFd; +use std::sync::Mutex; +use std::sync::OnceLock; +use thiserror::Error; + +/// Errors that can occur while taking an ownership of `RawFd` +#[derive(Debug, PartialEq, Error)] +pub enum Error { + /// init_once() not called + #[error("init_once() not called")] + NotInitialized, + + /// Ownership already taken + #[error("Ownership of FD {0} is already taken")] + OwnershipTaken(RawFd), + + /// Not an inherited file descriptor + #[error("FD {0} is either invalid file descriptor or not an inherited one")] + FileDescriptorNotInherited(RawFd), +} + +static INHERITED_FDS: OnceLock>>> = OnceLock::new(); + +/// Take ownership of all open file descriptors in this process, which later can be obtained by +/// calling `take_fd_ownership`. Set the FD_CLOEXEC on all of these file descriptors. +/// +/// # Safety +/// This function has to be called very early in the program before the ownership of any file +/// descriptors (except stdin/out/err) is taken. +pub unsafe fn init_once() -> Result<(), std::io::Error> { + let mut fds = HashMap::new(); + + let fd_path = canonicalize("/proc/self/fd")?; + + for entry in read_dir(&fd_path)? { + let entry = entry?; + + // Files in /prod/self/fd are guaranteed to be numbers. So parsing is always successful. + let file_name = entry.file_name(); + let raw_fd = file_name.to_str().unwrap().parse::().unwrap(); + + // We don't take ownership of the stdio FDs as the Rust runtime owns them. + if [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO].contains(&raw_fd) { + continue; + } + + // Exceptional case: /proc/self/fd/* may be a dir fd created by read_dir just above. Since + // the file descriptor is owned by read_dir (and thus closed by it), we shouldn't take + // ownership to it. + if entry.path().read_link()? == fd_path { + continue; + } + + fcntl(raw_fd, F_SETFD(FdFlag::FD_CLOEXEC))?; + + // SAFETY: /proc/self/fd/* are file descriptors that are open. If `init_once()` was called + // at the very beginning of the program execution (as requested by the safety requirement + // of this function), this is the first time to claim the ownership of these file + // descriptors. + let owned_fd = unsafe { OwnedFd::from_raw_fd(raw_fd) }; + fds.insert(raw_fd, Some(owned_fd)); + } + + INHERITED_FDS + .set(Mutex::new(fds)) + .or(Err(std::io::Error::other( + "Inherited fds were already initialized", + ))) +} + +/// Take the ownership of the given `RawFd` and returns `OwnedFd` for it. The returned FD is set +/// CLOEXEC. `Error` is returned when the ownership was already taken (by a prior call to this +/// function with the same `RawFd`) or `RawFd` is not an inherited file descriptor. +pub fn take_fd_ownership(raw_fd: RawFd) -> Result { + let mut fds = INHERITED_FDS + .get() + .ok_or(Error::NotInitialized)? + .lock() + .unwrap(); + + if let Some(value) = fds.get_mut(&raw_fd) { + if let Some(owned_fd) = value.take() { + Ok(owned_fd) + } else { + Err(Error::OwnershipTaken(raw_fd)) + } + } else { + Err(Error::FileDescriptorNotInherited(raw_fd)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + use nix::fcntl::{F_GETFD, F_SETFD, FdFlag, fcntl}; + use nix::unistd::close; + use std::os::fd::{AsRawFd, IntoRawFd}; + use tempfile::tempfile; + + struct Fixture { + fds: Vec, + } + + impl Fixture { + fn setup(num_fds: usize) -> Result { + let mut fds = Vec::new(); + for _ in 0..num_fds { + fds.push(tempfile()?.into_raw_fd()); + } + Ok(Fixture { fds }) + } + + fn open_new_file(&mut self) -> Result { + let raw_fd = tempfile()?.into_raw_fd(); + self.fds.push(raw_fd); + Ok(raw_fd) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + self.fds.iter().for_each(|fd| { + let _ = close(*fd); + }); + } + } + + fn is_fd_opened(raw_fd: RawFd) -> bool { + fcntl(raw_fd, F_GETFD).is_ok() + } + + #[test] + fn happy_case() -> Result<()> { + let fixture = Fixture::setup(2)?; + let f0 = fixture.fds[0]; + let f1 = fixture.fds[1]; + + // SAFETY: assume files opened by Fixture are inherited ones + unsafe { + init_once()?; + } + + let f0_owned = take_fd_ownership(f0)?; + let f1_owned = take_fd_ownership(f1)?; + assert_eq!(f0, f0_owned.as_raw_fd()); + assert_eq!(f1, f1_owned.as_raw_fd()); + + drop(f0_owned); + drop(f1_owned); + assert!(!is_fd_opened(f0)); + assert!(!is_fd_opened(f1)); + Ok(()) + } + + #[test] + fn access_non_inherited_fd() -> Result<()> { + let mut fixture = Fixture::setup(2)?; + + // SAFETY: assume files opened by Fixture are inherited ones + unsafe { + init_once()?; + } + + let f = fixture.open_new_file()?; + assert_eq!( + Some(Error::FileDescriptorNotInherited(f)), + take_fd_ownership(f).err() + ); + Ok(()) + } + + #[test] + fn call_init_once_multiple_times() -> Result<()> { + let _ = Fixture::setup(2)?; + + // SAFETY: assume files opened by Fixture are inherited ones + unsafe { + init_once()?; + } + + // SAFETY: for testing + let res = unsafe { init_once() }; + assert!(res.is_err()); + Ok(()) + } + + #[test] + fn access_without_init_once() -> Result<()> { + let fixture = Fixture::setup(2)?; + + let f = fixture.fds[0]; + assert_eq!(Some(Error::NotInitialized), take_fd_ownership(f).err()); + Ok(()) + } + + #[test] + fn double_ownership() -> Result<()> { + let fixture = Fixture::setup(2)?; + let f = fixture.fds[0]; + + // SAFETY: assume files opened by Fixture are inherited ones + unsafe { + init_once()?; + } + + let f_owned = take_fd_ownership(f)?; + let f_double_owned = take_fd_ownership(f); + assert_eq!(Some(Error::OwnershipTaken(f)), f_double_owned.err()); + + // just to highlight that f_owned is kept alive when the second call to take_fd_ownership + // is made. + drop(f_owned); + Ok(()) + } + + #[test] + fn take_drop_retake() -> Result<()> { + let fixture = Fixture::setup(2)?; + let f = fixture.fds[0]; + + // SAFETY: assume files opened by Fixture are inherited ones + unsafe { + init_once()?; + } + + let f_owned = take_fd_ownership(f)?; + drop(f_owned); + + let f_double_owned = take_fd_ownership(f); + assert_eq!(Some(Error::OwnershipTaken(f)), f_double_owned.err()); + Ok(()) + } + + #[test] + fn cloexec() -> Result<()> { + let fixture = Fixture::setup(2)?; + let f = fixture.fds[0]; + + fcntl(f, F_SETFD(FdFlag::empty()))?; + + // SAFETY: assume files opened by Fixture are inherited ones + unsafe { + init_once()?; + } + + // FD_CLOEXEC should be set by init_once + let flags = fcntl(f.as_raw_fd(), F_GETFD)?; + assert_eq!(flags, FdFlag::FD_CLOEXEC.bits()); + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 63bfd4e..be9568b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,7 @@ //! child.wait().unwrap(); //! ``` +pub mod inherited; #[cfg(feature = "tokio")] pub mod tokio; From e56960526e3fb5a41db3befdad271b849d052724 Mon Sep 17 00:00:00 2001 From: Andrew Walbran Date: Fri, 10 Apr 2026 14:40:20 +0100 Subject: [PATCH 2/5] Clean up and fix for new version of nix. --- Cargo.lock | 365 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 + src/inherited.rs | 175 ++++++++++++----------- 3 files changed, 461 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d7005d..b20af77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "bitflags" version = "2.9.0" @@ -31,16 +37,127 @@ name = "command-fds" version = "0.3.2" dependencies = [ "nix", + "tempfile", "thiserror", "tokio", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "mio" version = "1.2.0" @@ -64,12 +181,28 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -88,6 +221,73 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -108,6 +308,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -148,12 +361,70 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -168,3 +439,97 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 9756d8f..40c1d67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ tokio = { version = "1.51.0", optional = true, default-features = false, feature "process", ] } +[dev-dependencies] +tempfile = "3.27.0" + [features] default = [] tokio = ["dep:tokio"] diff --git a/src/inherited.rs b/src/inherited.rs index cb91b5a..ae5d375 100644 --- a/src/inherited.rs +++ b/src/inherited.rs @@ -12,25 +12,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Library for safely obtaining `OwnedFd` for inherited file descriptors. - -use nix::fcntl::{F_SETFD, FdFlag, fcntl}; -use nix::libc; -use std::collections::HashMap; -use std::fs::canonicalize; -use std::fs::read_dir; -use std::os::fd::FromRawFd; -use std::os::fd::OwnedFd; -use std::os::fd::RawFd; -use std::sync::Mutex; -use std::sync::OnceLock; +//! Utilities for safely obtaining `OwnedFd`s for inherited file descriptors. + +use nix::{ + fcntl::{F_SETFD, FdFlag, fcntl}, + libc, +}; +use std::{ + collections::HashMap, + fs::{canonicalize, read_dir}, + os::fd::{FromRawFd, OwnedFd, RawFd}, + sync::{Mutex, OnceLock}, +}; use thiserror::Error; +static INHERITED_FDS: OnceLock>>> = OnceLock::new(); + /// Errors that can occur while taking an ownership of `RawFd` #[derive(Debug, PartialEq, Error)] -pub enum Error { - /// init_once() not called - #[error("init_once() not called")] +pub enum InheritedFdError { + /// init_inherited_fds() not called + #[error("init_inherited_fds() not called")] NotInitialized, /// Ownership already taken @@ -42,15 +44,16 @@ pub enum Error { FileDescriptorNotInherited(RawFd), } -static INHERITED_FDS: OnceLock>>> = OnceLock::new(); - -/// Take ownership of all open file descriptors in this process, which later can be obtained by -/// calling `take_fd_ownership`. Set the FD_CLOEXEC on all of these file descriptors. +/// Takes ownership of all open file descriptors in this process other than standard +/// input/output/error, so that they can later be obtained by calling [`take_fd_ownership`]. +/// +/// Sets the `FD_CLOEXEC` flag on all of these file descriptors. /// /// # Safety -/// This function has to be called very early in the program before the ownership of any file -/// descriptors (except stdin/out/err) is taken. -pub unsafe fn init_once() -> Result<(), std::io::Error> { +/// +/// This must be called very early in the program, before the ownership of any file descriptors +/// (except stdin/out/err) is taken. +pub unsafe fn init_inherited_fds() -> Result<(), std::io::Error> { let mut fds = HashMap::new(); let fd_path = canonicalize("/proc/self/fd")?; @@ -74,13 +77,13 @@ pub unsafe fn init_once() -> Result<(), std::io::Error> { continue; } - fcntl(raw_fd, F_SETFD(FdFlag::FD_CLOEXEC))?; - - // SAFETY: /proc/self/fd/* are file descriptors that are open. If `init_once()` was called - // at the very beginning of the program execution (as requested by the safety requirement - // of this function), this is the first time to claim the ownership of these file - // descriptors. + // SAFETY: /proc/self/fd/* are file descriptors that are open. If `init_inherited_fds()` was + // called at the very beginning of the program execution (as requested by the safety + // requirement of this function), this is the first time to claim the ownership of these + // file descriptors. let owned_fd = unsafe { OwnedFd::from_raw_fd(raw_fd) }; + + fcntl(&owned_fd, F_SETFD(FdFlag::FD_CLOEXEC))?; fds.insert(raw_fd, Some(owned_fd)); } @@ -91,13 +94,16 @@ pub unsafe fn init_once() -> Result<(), std::io::Error> { ))) } -/// Take the ownership of the given `RawFd` and returns `OwnedFd` for it. The returned FD is set -/// CLOEXEC. `Error` is returned when the ownership was already taken (by a prior call to this +/// Takes the ownership of the given `RawFd` and returns an `OwnedFd` for it. +/// +/// The returned FD will have the `FD_CLOEXEC` flag set. +/// +/// An error is returned when the ownership was already taken (by a prior call to this /// function with the same `RawFd`) or `RawFd` is not an inherited file descriptor. -pub fn take_fd_ownership(raw_fd: RawFd) -> Result { +pub fn take_fd_ownership(raw_fd: RawFd) -> Result { let mut fds = INHERITED_FDS .get() - .ok_or(Error::NotInitialized)? + .ok_or(InheritedFdError::NotInitialized)? .lock() .unwrap(); @@ -105,20 +111,21 @@ pub fn take_fd_ownership(raw_fd: RawFd) -> Result { if let Some(owned_fd) = value.take() { Ok(owned_fd) } else { - Err(Error::OwnershipTaken(raw_fd)) + Err(InheritedFdError::OwnershipTaken(raw_fd)) } } else { - Err(Error::FileDescriptorNotInherited(raw_fd)) + Err(InheritedFdError::FileDescriptorNotInherited(raw_fd)) } } #[cfg(test)] mod test { use super::*; - use anyhow::Result; - use nix::fcntl::{F_GETFD, F_SETFD, FdFlag, fcntl}; use nix::unistd::close; - use std::os::fd::{AsRawFd, IntoRawFd}; + use std::{ + io, + os::fd::{AsRawFd, IntoRawFd}, + }; use tempfile::tempfile; struct Fixture { @@ -126,7 +133,7 @@ mod test { } impl Fixture { - fn setup(num_fds: usize) -> Result { + fn setup(num_fds: usize) -> Result { let mut fds = Vec::new(); for _ in 0..num_fds { fds.push(tempfile()?.into_raw_fd()); @@ -134,7 +141,7 @@ mod test { Ok(Fixture { fds }) } - fn open_new_file(&mut self) -> Result { + fn open_new_file(&mut self) -> Result { let raw_fd = tempfile()?.into_raw_fd(); self.fds.push(raw_fd); Ok(raw_fd) @@ -150,22 +157,22 @@ mod test { } fn is_fd_opened(raw_fd: RawFd) -> bool { - fcntl(raw_fd, F_GETFD).is_ok() + unsafe { libc::fcntl(raw_fd, libc::F_GETFD) != -1 } } #[test] - fn happy_case() -> Result<()> { - let fixture = Fixture::setup(2)?; + fn happy_case() { + let fixture = Fixture::setup(2).unwrap(); let f0 = fixture.fds[0]; let f1 = fixture.fds[1]; // SAFETY: assume files opened by Fixture are inherited ones unsafe { - init_once()?; + init_inherited_fds().unwrap(); } - let f0_owned = take_fd_ownership(f0)?; - let f1_owned = take_fd_ownership(f1)?; + let f0_owned = take_fd_ownership(f0).unwrap(); + let f1_owned = take_fd_ownership(f1).unwrap(); assert_eq!(f0, f0_owned.as_raw_fd()); assert_eq!(f1, f1_owned.as_raw_fd()); @@ -173,104 +180,108 @@ mod test { drop(f1_owned); assert!(!is_fd_opened(f0)); assert!(!is_fd_opened(f1)); - Ok(()) } #[test] - fn access_non_inherited_fd() -> Result<()> { - let mut fixture = Fixture::setup(2)?; + fn access_non_inherited_fd() { + let mut fixture = Fixture::setup(2).unwrap(); // SAFETY: assume files opened by Fixture are inherited ones unsafe { - init_once()?; + init_inherited_fds().unwrap(); } - let f = fixture.open_new_file()?; + let f = fixture.open_new_file().unwrap(); assert_eq!( - Some(Error::FileDescriptorNotInherited(f)), - take_fd_ownership(f).err() + take_fd_ownership(f).err(), + Some(InheritedFdError::FileDescriptorNotInherited(f)) ); - Ok(()) } #[test] - fn call_init_once_multiple_times() -> Result<()> { - let _ = Fixture::setup(2)?; + fn call_init_inherited_fds_multiple_times() { + let _ = Fixture::setup(2).unwrap(); // SAFETY: assume files opened by Fixture are inherited ones unsafe { - init_once()?; + init_inherited_fds().unwrap(); } // SAFETY: for testing - let res = unsafe { init_once() }; + let res = unsafe { init_inherited_fds() }; assert!(res.is_err()); - Ok(()) } #[test] - fn access_without_init_once() -> Result<()> { - let fixture = Fixture::setup(2)?; + fn access_without_init_inherited_fds() { + let fixture = Fixture::setup(2).unwrap(); let f = fixture.fds[0]; - assert_eq!(Some(Error::NotInitialized), take_fd_ownership(f).err()); - Ok(()) + assert_eq!( + take_fd_ownership(f).err(), + Some(InheritedFdError::NotInitialized) + ); } #[test] - fn double_ownership() -> Result<()> { - let fixture = Fixture::setup(2)?; + fn double_ownership() { + let fixture = Fixture::setup(2).unwrap(); let f = fixture.fds[0]; // SAFETY: assume files opened by Fixture are inherited ones unsafe { - init_once()?; + init_inherited_fds().unwrap(); } - let f_owned = take_fd_ownership(f)?; + let f_owned = take_fd_ownership(f).unwrap(); let f_double_owned = take_fd_ownership(f); - assert_eq!(Some(Error::OwnershipTaken(f)), f_double_owned.err()); + assert_eq!( + f_double_owned.err(), + Some(InheritedFdError::OwnershipTaken(f)), + ); // just to highlight that f_owned is kept alive when the second call to take_fd_ownership // is made. drop(f_owned); - Ok(()) } #[test] - fn take_drop_retake() -> Result<()> { - let fixture = Fixture::setup(2)?; + fn take_drop_retake() { + let fixture = Fixture::setup(2).unwrap(); let f = fixture.fds[0]; // SAFETY: assume files opened by Fixture are inherited ones unsafe { - init_once()?; + init_inherited_fds().unwrap(); } - let f_owned = take_fd_ownership(f)?; + let f_owned = take_fd_ownership(f).unwrap(); drop(f_owned); let f_double_owned = take_fd_ownership(f); - assert_eq!(Some(Error::OwnershipTaken(f)), f_double_owned.err()); - Ok(()) + assert_eq!( + f_double_owned.err(), + Some(InheritedFdError::OwnershipTaken(f)), + ); } #[test] - fn cloexec() -> Result<()> { - let fixture = Fixture::setup(2)?; + fn cloexec() { + let fixture = Fixture::setup(2).unwrap(); let f = fixture.fds[0]; - fcntl(f, F_SETFD(FdFlag::empty()))?; + let res = unsafe { libc::fcntl(f.as_raw_fd(), libc::F_SETFD, 0) }; + assert_ne!(res, -1); // SAFETY: assume files opened by Fixture are inherited ones unsafe { - init_once()?; + init_inherited_fds().unwrap(); } - // FD_CLOEXEC should be set by init_once - let flags = fcntl(f.as_raw_fd(), F_GETFD)?; + // SAFETY: F_GETFD doesn't need any extra parameters. + let flags = unsafe { libc::fcntl(f.as_raw_fd(), libc::F_GETFD) }; + assert_ne!(flags, -1); + // FD_CLOEXEC should be set by init_inherited_fds assert_eq!(flags, FdFlag::FD_CLOEXEC.bits()); - - Ok(()) } } From 42ea5a7b717b7a57c211ad14eeec27851a0fcb12 Mon Sep 17 00:00:00 2001 From: Andrew Walbran Date: Fri, 10 Apr 2026 15:09:42 +0100 Subject: [PATCH 3/5] Run tests with cargo-nextest. The inherited FD tests need to be run in separate processes, which this achieves. --- .github/workflows/rust.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 50743b1..622d37d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -15,10 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: taiki-e/install-action@nextest - name: Build run: cargo build - name: Run tests - run: cargo test + run: cargo nextest run + - name: Run doctests + run: cargo test --doc - name: Run clippy uses: actions-rs/clippy-check@v1 with: @@ -38,12 +41,10 @@ jobs: RUSTC_BOOTSTRAP: 1 steps: - uses: actions/checkout@v6 - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov + - uses: taiki-e/install-action@nextest + - uses: taiki-e/install-action@cargo-llvm-cov - name: Run tests with coverage - run: cargo llvm-cov test --all-features --codecov --output-path codecov-report.json + run: cargo llvm-cov nextest --all-features --codecov --output-path codecov-report.json - name: Upload coverage to codecov.io uses: codecov/codecov-action@v6 with: From 56456ddbf424c4c69489ea4d96368aa50cc47dc5 Mon Sep 17 00:00:00 2001 From: Andrew Walbran Date: Fri, 10 Apr 2026 15:28:04 +0100 Subject: [PATCH 4/5] Add child process example to readme and rustdoc. --- README.md | 27 ++++++++++++++++++++++++++- src/lib.rs | 29 +++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 71a2822..7fefd73 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,13 @@ [![crates.io page](https://img.shields.io/crates/v/command-fds.svg)](https://crates.io/crates/command-fds) [![docs.rs page](https://docs.rs/command-fds/badge.svg)](https://docs.rs/command-fds) -A library for passing arbitrary file descriptors when spawning child processes. +A library for passing arbitrary file descriptors when spawning child processes, and safely taking +ownership of passed file descriptors within such a child process. ## Example +In the parent process: + ```rust use command_fds::{CommandFdExt, FdMapping}; use std::fs::File; @@ -41,6 +44,28 @@ let mut child = command.spawn().unwrap(); child.wait().unwrap(); ``` +In the child process: + +```rust +use command_fds::inherited::{init_inherited_fds, take_fd_ownership}; + +fn main() { + // SAFETY: This is called before anything else in the program. + unsafe { + init_inherited_fds(); + } + + // Get an OwnedFd for the file that was passed as FD 3 by the parent. + let inherited_file = take_fd_ownership(3).unwrap(); + + // Trying to take the same file descriptor again will return an error. + take_fd_ownership(3).expect_err("Can't take the same FD twice"); + + // Trying to take a file descriptor which wasn't passed will also return an error. + take_fd_ownership(4).expect_err("Can't take an FD which wasn't inherited"); +} +``` + ## License Licensed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). diff --git a/src/lib.rs b/src/lib.rs index be9568b..10fb136 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,11 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! A library for passing arbitrary file descriptors when spawning child processes. +//! A library for passing arbitrary file descriptors when spawning child processes, and safely +//! taking ownership of passed file descriptors within such a child process. //! //! # Example //! -//! ```rust +//! In the parent process: +//! +//! ``` //! use command_fds::{CommandFdExt, FdMapping}; //! use std::fs::File; //! use std::io::stdin; @@ -49,6 +52,28 @@ //! let mut child = command.spawn().unwrap(); //! child.wait().unwrap(); //! ``` +//! +//! In the child process: +//! +//! ```no_run +//! use command_fds::inherited::{init_inherited_fds, take_fd_ownership}; +//! +//! fn main() { +//! // SAFETY: This is called before anything else in the program. +//! unsafe { +//! init_inherited_fds(); +//! } +//! +//! // Get an OwnedFd for the file that was passed as FD 3 by the parent. +//! let inherited_file = take_fd_ownership(3).unwrap(); +//! +//! // Trying to take the same file descriptor again will return an error. +//! take_fd_ownership(3).expect_err("Can't take the same FD twice"); +//! +//! // Trying to take a file descriptor which wasn't passed will also return an error. +//! take_fd_ownership(4).expect_err("Can't take an FD which wasn't inherited"); +//! } +//! ``` pub mod inherited; #[cfg(feature = "tokio")] From 9ab84d472eb3e9ae609b7f95bc852d4aaa1deb10 Mon Sep 17 00:00:00 2001 From: Andrew Walbran Date: Fri, 10 Apr 2026 15:28:15 +0100 Subject: [PATCH 5/5] Add changelog. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2af6f6b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +### New features + +- Added `inherited` module for taking ownership of inherited file descriptors.