Rust Sanitizes Standard Streams
Preamble
A programming language--in a hosted environment--has two crucial components: language features and runtime capabilities. For example, features such as traits are language features and it describes the functions (methods) a type may implement, borrow checker which statically analyzes program source to find common errors, and checks that are enforced during compilation are language features. Garbage collection, memory access checks, and other features that are available during program execution are runtime capabilities. We'll look into what happens when a hosted environment closes the standard streams of a Rust program along with masking the /dev directory. This directory is available in most UNIX-like systems, which is what I primarily use.
Out-Of-Bounds in Rust
Before getting into the abort(3) behavior of runtime initialization, let's also briefly discuss how array's element access is checked by the runtime, a feature which is not available in C unless tools such as ASan are probed during compilation.
Writing faulty program in Rust is extremely difficult. Even if the program is prone to bugs, the runtime is able to catch errors such as out-of-bounds access. In languages such as C and C++, attempt to access out-of-bound memory is not well-defined, or what people mostly refer to as, undefined. If the memory access lies in the valid memory region of the program's address space, it's not considered a violation. But if the memory access lies in the invalid memory region, it introduced segmentation violation. I've talked about it in lengths in [Managing Memory Through Address Sanitizer and Observing Virtual Memory]. Below is a simple Rust program which takes a user input, and uses it as an index to an array. If the input number is greater than the length of the array, the program panics.
use std::io::{self, Write};
fn main() {
let numbers: [i32; 5] = [10, 20, 30, 40, 50];
print!("Enter an index: ");
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let index: usize = input.trim().parse().unwrap();
println!("The value at index {} is {}", index, numbers[index]);
}
If the user typed a number greater than 4, the following output is displayed:
thread 'main' (12) panicked at src/main.rs:14:61:
index out of bounds: the len is 5 but the index is 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
For an array data type, the runtime ensures that the index is in range, and if not, panics. Believe it or not, this is also an overhead, albeit a trivial one which has more benefits than issues.
Rust Runtime When Standard Streams Are Closed
Consider a trivial Rust program:
// printout.rs
fn main() {
println!("Hello, World!");
}
It simply prints out Hello, World! to the standard output stream. Pretty basic and universal program most people have already encountered! But what if we close the standard output stream?
$ ./printout >&-
$ echo $?
0
Nothing special happened. As expected, no output was shown since the stream has already been closed. The following command might be a bit complicated, but I'll try to briefly explain what's happening. We now mask the /dev directory for the program and check out its output:
# unshare -m bash -c "mount -t tmpfs tmpfs /dev && ./printout >&-"
bash: line 1: 2547 Aborted (core dumped) ./printout 1>&-
Aborted!? Why? Before understanding the why, let's first understand what's happening in the above shown shell command. As per unshare(1) manual, it is used to create new namespace. Namespaces are a feature of the Linux kernel that partitions the kernel resources such that one set of processes sees one set of resources, while another set of processes sees a different set of resources. Refer to [Wikipedia: Linux namespaces] for more informatiom regarding namespaces. Various namespaces are described in namespaces(7) manual documentation. Getting back to unshare(1), we use the -m flag, which indicates to unshare the mount namespace. This allows the program to have its own distinct view of the filesystem as compared to other processes. We require this since the program (bash) calls the mount(1) utility.
Once the namespace is created, bash(1) is executed with the -c flag. This flag essentially allows bash to execute the first non-option argument. In our case, the program is wrapped inside double quotes.
The mount(1) command is usually invoked as:
mount -t type device dir
It tells the kernel to attach the filesystem found on device (which is of type type) at the directory dir. The filesystem tmpfs is a special filesystem. tmpfs(5) defines it as a virtual memory filesystem. In our case, using this command allows us to mount the virtual memory filesystem over the /dev directory. The contents of tmpfs only persists until it is unmounted. Given that the bash(1) program has its own distinct mount namespace and we just mounted a temporary filesystem over the /dev directory, usual files that are found inside /dev directory are no longer accessible by the program. This includes the /dev/null file.
What's so special about the /dev/null file?
Files inside the /dev directory normally represents devices. Checking the status of such files, we notice that they have associated device major and device minor numbers. There are various special files in /dev directory. /dev/null is a special file which discards any writes to the file and returns 0 (EOF) when the file is read. This is also explained in null(4).
The Runtime Initialization Function
It is assumed that main is the entry point of a program in many languages. But the language runtime must also be initialized before executing lines from the source file. On UNIX-like systems, [std::sys::pal::unix] is executed which contains the init function that is called once during runtime initialization. A snippet of the init function and the sanitize_standard_fds function defined within the function is:
#[cfg(not(target_os = "espidf"))]
#[cfg_attr(target_os = "vita", allow(unused_variables))]
// SAFETY: must be called only once during runtime initialization.
// NOTE: this is not guaranteed to run, for example when Rust code is called externally.
// See `fn init()` in `library/std/src/rt.rs` for docs on `sigpipe`.
pub unsafe fn init(argc: isize, argv: *const *const u8, sigpipe: u8) {
// The standard streams might be closed on application startup. To prevent
// std::io::{stdin, stdout,stderr} objects from using other unrelated file
// resources opened later, we reopen standards streams when they are closed.
sanitize_standard_fds();
...
unsafe fn sanitize_standard_fds() {
#[allow(dead_code, unused_variables, unused_mut)]
let mut opened_devnull = -1;
#[allow(dead_code, unused_variables, unused_mut)]
let mut open_devnull = || {
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
use libc::open;
#[cfg(all(target_os = "linux", target_env = "gnu"))]
use libc::open64 as open;
if opened_devnull != -1 {
if libc::dup(opened_devnull) != -1 {
return;
}
}
opened_devnull = open(c"/dev/null".as_ptr(), libc::O_RDWR, 0);
if opened_devnull == -1 {
// If the stream is closed but we failed to reopen it, abort the
// process. Otherwise we wouldn't preserve the safety of
// operations on the corresponding Rust object Stdin, Stdout, or
// Stderr.
libc::abort();
}
};
// fast path with a single syscall for systems with poll()
#[cfg(not(any(
miri,
target_os = "emscripten",
target_os = "fuchsia",
target_os = "vxworks",
target_os = "redox",
target_os = "l4re",
target_os = "horizon",
target_os = "vita",
target_os = "rtems",
// The poll on Darwin doesn't set POLLNVAL for closed fds.
target_vendor = "apple",
)))]
'poll: {
use crate::sys::io::errno;
let pfds: &mut [_] = &mut [
libc::pollfd { fd: 0, events: 0, revents: 0 },
libc::pollfd { fd: 1, events: 0, revents: 0 },
libc::pollfd { fd: 2, events: 0, revents: 0 },
];
while libc::poll(pfds.as_mut_ptr(), 3, 0) == -1 {
match errno() {
libc::EINTR => continue,
#[cfg(target_vendor = "unikraft")]
libc::ENOSYS => {
// Not all configurations of Unikraft enable `LIBPOSIX_EVENT`.
break 'poll;
}
libc::EINVAL | libc::EAGAIN | libc::ENOMEM => {
// RLIMIT_NOFILE or temporary allocation failures
// may be preventing use of poll(), fall back to fcntl
break 'poll;
}
_ => libc::abort(),
}
}
for pfd in pfds {
if pfd.revents & libc::POLLNVAL == 0 {
continue;
}
open_devnull();
}
return;
}
// fallback in case poll isn't available or limited by RLIMIT_NOFILE
#[cfg(not(any(
// The standard fds are always available in Miri.
miri,
target_os = "emscripten",
target_os = "fuchsia",
target_os = "vxworks",
target_os = "l4re",
target_os = "horizon",
target_os = "vita",
)))]
{
use crate::sys::io::errno;
for fd in 0..3 {
if libc::fcntl(fd, libc::F_GETFD) == -1 && errno() == libc::EBADF {
open_devnull();
}
}
}
}
...
}
Notice that sanitize_standard_fds is immediately called inside the init function. The closure object open_devnull is the one we're interested in. It captures the environment since we see opened_devnull inside the closure definition. This implies that open_devnull's hidden environment struct stores a mutable reference to opened_devnull. Mutable reference since opened_devnull is both read and written to inside the closure. The if expression is false initially and open is called whose return value is stored in opened_devnull. Notice that /dev/null file is opened. The comments are self explanatory as to why this file is opened in case the standard streams are closed. Anyways, if we failed to open the /dev/null file, abort(3) is called. Remember, the closure object open_devnull is not called till now. It is simply instantiated but not invoked. The block below it will invoke this closure, which eventually aborts the program.
Nested function is a feature available in most languages. Even C supports it. Well... the standard does not mention anything about nested functions, but compilers provide support for this concept. [GNU GCC: Nested Functions] provides sufficient information for understanding this concept.
Following the closure definition, we see two conditional compilation block. The 'poll loop label is the "fast" path, and the other path is for systems where poll isn't available or limited by RTLIMIT_NOFILE. I'll discuss the fast path. poll(2) is the successor to the classic select(2) system call (and is superceded by epoll(2) in Linux.) It is used to wait for events in the file descriptor. Polling can be: nonblocking (0), specified blocking (positive timeout value), or infinite blocking (negative timeout value). We see that a nonblocking polling is performed. Do note that the returned events fields of descriptors are populated even when non-blocking timeout is set. All three standard file descriptors are checked. According to poll(2), POLLNVAL in revents implies that the file descriptor was not opened. If this is the case, open_devnull closure is called. Only after that, would an attempt be made to open /dev/null, thereby causing the abort.
Before executing the instructions from the source file, the runtime prematurely aborts due to absence of the /dev/null file. [Behavior of exec function] in POSIX states following:
If file descriptor 0, 1, or 2 would otherwise be closed after a successful call to one of the exec family of functions, implementations may open an unspecified file for the file descriptor in the new process image. If a standard utility or a conforming application is executed with file descriptor 0 not open for reading or with file descriptor 1 or 2 not open for writing, the environment in which the utility or application is executed shall be deemed non-conforming, and consequently the utility or application might not behave as described in this standard.
Conclusion
If a command is executed with the standard streams being closed, it is not deemed to be executing in a conforming environment, according to POSIX. Rust's runtime checks for this, and attempts to assign the /dev/null file to the descriptors assigned for standard streams to avoid writing to unwanted files. But if we manage to mask the /dev/null file itself, Rust runtime aborts the program to ensure the safety of operations on corresponding Rust objects Stdin, Stdout, or Stderr.
