aboutsummaryrefslogtreecommitdiff
path: root/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib.rs')
-rw-r--r--src/lib.rs334
1 files changed, 334 insertions, 0 deletions
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..8ce1071
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,334 @@
+// Copyright 2021, 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.
+
+//! A library for passing arbitrary file descriptors when spawning child processes.
+//!
+//! # Example
+//!
+//! ```rust
+//! use command_fds::{CommandFdExt, FdMapping};
+//! use std::fs::File;
+//! use std::os::unix::io::AsRawFd;
+//! use std::process::Command;
+//!
+//! // Open a file.
+//! let file = File::open("Cargo.toml").unwrap();
+//!
+//! // Prepare to run `ls -l /proc/self/fd` with some FDs mapped.
+//! let mut command = Command::new("ls");
+//! command.arg("-l").arg("/proc/self/fd");
+//! command
+//! .fd_mappings(vec![
+//! // Map `file` as FD 3 in the child process.
+//! FdMapping {
+//! parent_fd: file.as_raw_fd(),
+//! child_fd: 3,
+//! },
+//! // Map this process's stdin as FD 5 in the child process.
+//! FdMapping {
+//! parent_fd: 0,
+//! child_fd: 5,
+//! },
+//! ])
+//! .unwrap();
+//!
+//! // Spawn the child process.
+//! let mut child = command.spawn().unwrap();
+//! child.wait().unwrap();
+//! ```
+
+use nix::fcntl::{fcntl, FcntlArg};
+use nix::unistd::dup2;
+use std::cmp::max;
+use std::io::{self, ErrorKind};
+use std::os::unix::io::RawFd;
+use std::os::unix::process::CommandExt;
+use std::process::Command;
+use thiserror::Error;
+
+/// A mapping from a file descriptor in the parent to a file descriptor in the child, to be applied
+/// when spawning a child process.
+///
+/// The parent_fd must be kept open until after the child is spawned.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FdMapping {
+ pub parent_fd: RawFd,
+ pub child_fd: RawFd,
+}
+
+/// Error setting up FD mappings, because there were two or more mappings for the same child FD.
+#[derive(Copy, Clone, Debug, Eq, Error, PartialEq)]
+#[error("Two or more mappings for the same child FD")]
+pub struct FdMappingCollision;
+
+/// Extension to add file descriptor mappings to a [`Command`].
+pub trait CommandFdExt {
+ /// Adds the given set of file descriptor to the command.
+ ///
+ /// Calling this more than once on the same command may result in unexpected behaviour.
+ fn fd_mappings(&mut self, mappings: Vec<FdMapping>) -> Result<(), FdMappingCollision>;
+}
+
+impl CommandFdExt for Command {
+ fn fd_mappings(&mut self, mappings: Vec<FdMapping>) -> Result<(), FdMappingCollision> {
+ // Validate that there are no conflicting mappings to the same child FD.
+ let mut child_fds: Vec<RawFd> = mappings.iter().map(|mapping| mapping.child_fd).collect();
+ child_fds.sort_unstable();
+ child_fds.dedup();
+ if child_fds.len() != mappings.len() {
+ return Err(FdMappingCollision);
+ }
+
+ // Register the callback to apply the mappings after forking but before execing.
+ unsafe {
+ self.pre_exec(move || map_fds(&mappings));
+ }
+
+ Ok(())
+ }
+}
+
+fn map_fds(mappings: &[FdMapping]) -> io::Result<()> {
+ if mappings.is_empty() {
+ // No need to do anything, and finding first_unused_fd would fail.
+ return Ok(());
+ }
+
+ // Find the first FD which is higher than any parent or child FD in the mapping, so we can
+ // safely use it and higher FDs as temporary FDs. There may be other files open with these FDs,
+ // so we still need to ensure we don't conflict with them.
+ let first_safe_fd = mappings
+ .iter()
+ .map(|mapping| max(mapping.parent_fd, mapping.child_fd))
+ .max()
+ .unwrap()
+ + 1;
+
+ // If any parent FDs conflict with child FDs, then first duplicate them to a temporary FD which
+ // is clear of either range.
+ let child_fds: Vec<RawFd> = mappings.iter().map(|mapping| mapping.child_fd).collect();
+ let mappings = mappings
+ .iter()
+ .map(|mapping| {
+ Ok(if child_fds.contains(&mapping.parent_fd) {
+ let temporary_fd =
+ fcntl(mapping.parent_fd, FcntlArg::F_DUPFD_CLOEXEC(first_safe_fd))?;
+ FdMapping {
+ parent_fd: temporary_fd,
+ child_fd: mapping.child_fd,
+ }
+ } else {
+ mapping.to_owned()
+ })
+ })
+ .collect::<nix::Result<Vec<_>>>()
+ .map_err(nix_to_io_error)?;
+
+ // Now we can actually duplicate FDs to the desired child FDs.
+ for mapping in mappings {
+ // This closes child_fd if it is already open as something else, and clears the FD_CLOEXEC
+ // flag on child_fd.
+ dup2(mapping.parent_fd, mapping.child_fd).map_err(nix_to_io_error)?;
+ }
+
+ Ok(())
+}
+
+/// Convert a [`nix::Error`] to a [`std::io::Error`].
+fn nix_to_io_error(error: nix::Error) -> io::Error {
+ if let nix::Error::Sys(errno) = error {
+ io::Error::from_raw_os_error(errno as i32)
+ } else {
+ io::Error::new(ErrorKind::Other, error)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use nix::unistd::close;
+ use std::collections::HashSet;
+ use std::fs::{read_dir, File};
+ use std::os::unix::io::AsRawFd;
+ use std::process::Output;
+ use std::str;
+ use std::sync::Once;
+
+ static SETUP: Once = Once::new();
+
+ #[test]
+ fn conflicting_mappings() {
+ setup();
+
+ let mut command = Command::new("ls");
+
+ // The same mapping can't be included twice.
+ assert_eq!(
+ command.fd_mappings(vec![
+ FdMapping {
+ child_fd: 4,
+ parent_fd: 5,
+ },
+ FdMapping {
+ child_fd: 4,
+ parent_fd: 5,
+ },
+ ]),
+ Err(FdMappingCollision)
+ );
+
+ // Mapping two different FDs to the same FD isn't allowed either.
+ assert_eq!(
+ command.fd_mappings(vec![
+ FdMapping {
+ child_fd: 4,
+ parent_fd: 5,
+ },
+ FdMapping {
+ child_fd: 4,
+ parent_fd: 6,
+ },
+ ]),
+ Err(FdMappingCollision)
+ );
+ }
+
+ #[test]
+ fn no_mappings() {
+ setup();
+
+ let mut command = Command::new("ls");
+ command.arg("/proc/self/fd");
+
+ assert_eq!(command.fd_mappings(vec![]), Ok(()));
+
+ let output = command.output().unwrap();
+ expect_fds(&output, &[0, 1, 2, 3], 0);
+ }
+
+ #[test]
+ fn one_mapping() {
+ setup();
+
+ let mut command = Command::new("ls");
+ command.arg("/proc/self/fd");
+
+ let file = File::open("testdata/file1.txt").unwrap();
+ // Map the file an otherwise unused FD.
+ assert_eq!(
+ command.fd_mappings(vec![FdMapping {
+ parent_fd: file.as_raw_fd(),
+ child_fd: 5,
+ },]),
+ Ok(())
+ );
+
+ let output = command.output().unwrap();
+ expect_fds(&output, &[0, 1, 2, 3, 5], 0);
+ }
+
+ #[test]
+ fn swap_mappings() {
+ setup();
+
+ let mut command = Command::new("ls");
+ command.arg("/proc/self/fd");
+
+ let file1 = File::open("testdata/file1.txt").unwrap();
+ let file2 = File::open("testdata/file2.txt").unwrap();
+ let fd1 = file1.as_raw_fd();
+ let fd2 = file2.as_raw_fd();
+ // Map files to each other's FDs, to ensure that the temporary FD logic works.
+ assert_eq!(
+ command.fd_mappings(vec![
+ FdMapping {
+ parent_fd: fd1,
+ child_fd: fd2,
+ },
+ FdMapping {
+ parent_fd: fd2,
+ child_fd: fd1,
+ },
+ ]),
+ Ok(())
+ );
+
+ let output = command.output().unwrap();
+ // Expect one more Fd for the /proc/self/fd directory. We can't predict what number it will
+ // be assigned, because 3 might or might not be taken already by fd1 or fd2.
+ expect_fds(&output, &[0, 1, 2, fd1, fd2], 1);
+ }
+
+ #[test]
+ fn map_stdin() {
+ setup();
+
+ let mut command = Command::new("cat");
+
+ let file = File::open("testdata/file1.txt").unwrap();
+ // Map the file to stdin.
+ assert_eq!(
+ command.fd_mappings(vec![FdMapping {
+ parent_fd: file.as_raw_fd(),
+ child_fd: 0,
+ },]),
+ Ok(())
+ );
+
+ let output = command.output().unwrap();
+ assert!(output.status.success());
+ assert_eq!(output.stdout, b"test 1");
+ }
+
+ /// Parse the output of ls into a set of filenames
+ fn parse_ls_output(output: &[u8]) -> HashSet<String> {
+ str::from_utf8(output)
+ .unwrap()
+ .split_terminator("\n")
+ .map(str::to_owned)
+ .collect()
+ }
+
+ /// Check that the output of `ls /proc/self/fd` contains the expected set of FDs, plus exactly
+ /// `extra` extra FDs.
+ fn expect_fds(output: &Output, expected_fds: &[RawFd], extra: usize) {
+ assert!(output.status.success());
+ let expected_fds: HashSet<String> = expected_fds.iter().map(RawFd::to_string).collect();
+ let fds = parse_ls_output(&output.stdout);
+ if extra == 0 {
+ assert_eq!(fds, expected_fds);
+ } else {
+ assert!(expected_fds.is_subset(&fds));
+ assert_eq!(fds.len(), expected_fds.len() + extra);
+ }
+ }
+
+ fn setup() {
+ SETUP.call_once(close_excess_fds);
+ }
+
+ /// Close all file descriptors apart from stdin, stdout and stderr.
+ ///
+ /// This is necessary because GitHub Actions opens a bunch of others for some reason.
+ fn close_excess_fds() {
+ let dir = read_dir("/proc/self/fd").unwrap();
+ for entry in dir {
+ let entry = entry.unwrap();
+ let fd: RawFd = entry.file_name().to_str().unwrap().parse().unwrap();
+ if fd > 3 {
+ close(fd).unwrap();
+ }
+ }
+ }
+}