Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/uu/stdbuf/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ libstdbuf = { package = "uu_stdbuf_libstdbuf", version = "0.10.0", path = "src/l
# on some platforms, e.g. because the SELinux permissions may not allow
# stdbuf to write to /tmp, /tmp may be read-only, libstdbuf.so may not work
# at all without SELinux labels, etc.
# See https://github.com/rust-lang/cargo/issues/8317, still unresolved.
#
# 2. Installation of uutils-coreutils using an external tool, e.g. dpkg/apt on
# debian. In this case, libstdbuf.so should be installed separately to its
Expand Down
44 changes: 33 additions & 11 deletions src/uu/stdbuf/src/stdbuf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@

use clap::{Arg, ArgAction, ArgMatches, Command};
use std::ffi::OsString;
#[cfg(not(feature = "feat_external_libstdbuf"))]
use std::fs::Permissions;
#[cfg(not(feature = "feat_external_libstdbuf"))]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process;
use tempfile::TempDir;
use tempfile::tempdir;
use thiserror::Error;
use uucore::display::Quotable;
use uucore::error::{UResult, USimpleError, UUsageError, quiet_if_reported, strip_errno};
Expand Down Expand Up @@ -185,13 +187,19 @@ fn set_command_env(command: &mut process::Command, buffer_name: &str, buffer_typ

#[cfg(not(feature = "feat_external_libstdbuf"))]
fn get_preload_env(tmp_dir: &TempDir) -> UResult<(String, PathBuf)> {
use std::fs::File;
use std::fs::OpenOptions;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

let (preload, extension) = preload_strings();
let inject_path = tmp_dir.path().join("libstdbuf").with_extension(extension);

let mut file = File::create(&inject_path)?;
let mut open_options = OpenOptions::new();
open_options.write(true).create_new(true);
#[cfg(unix)]
open_options.mode(0o600);
let mut file = open_options.open(&inject_path)?;
file.write_all(STDBUF_INJECT)?;

Ok((preload.to_owned(), inject_path))
Expand Down Expand Up @@ -266,9 +274,23 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let mut command = process::Command::new(first_command);
let command_params: Vec<&OsString> = command_values.collect();

let tmp_dir = tempdir()
// When embedding the library, create a private (0700) temporary directory.
// The TempDir is kept alive until after the child exits so that the dynamic
// linker can load the .so, then dropped automatically.
// Mode is set at creation (not via a later chmod) so the directory is never
// world-accessible between create and restrict.
#[cfg(not(feature = "feat_external_libstdbuf"))]
let _tmp_dir = tempfile::Builder::new()
.permissions(Permissions::from_mode(0o700))
.tempdir()
.map_err(|e| UUsageError::new(125, format!("failed to create temp directory: {e}")))?;
let (preload_env, libstdbuf) = get_preload_env(&tmp_dir)?;
#[cfg(not(feature = "feat_external_libstdbuf"))]
let (preload_env, libstdbuf) = get_preload_env(&_tmp_dir)?;
#[cfg(feature = "feat_external_libstdbuf")]
let (preload_env, libstdbuf) =
get_preload_env(&tempfile::tempdir().map_err(|e| {
UUsageError::new(125, format!("failed to create temp directory: {e}"))
})?)?;
// The preload variable is a colon-separated list with no escaping mechanism,
// so a path containing ':' does not round-trip: the dynamic loader splits it
// and treats the leading component as a library to load. Since the temp
Expand All @@ -287,18 +309,18 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
set_command_env(&mut command, "_STDBUF_E", &options.stderr);
command.args(command_params);

// Replace the current process with the target program (no fork) using exec.
#[cfg(unix)]
let e = command.exec();
#[cfg(windows)]
// Spawn a child so the TempDir destructor fires in the parent, cleaning up
// the temporary directory. exec() would replace this process before the
// destructor runs, leaking the dir on every invocation.
let e = match command.spawn() {
Ok(mut child) => {
let status = child.wait().unwrap();
#[cfg(not(feature = "feat_external_libstdbuf"))]
drop(_tmp_dir); // cleanup after child exits — .so no longer needed
process::exit(status.code().unwrap_or(0));
}
Err(err) => err,
};
// exec() only returns if there was an error
let exit_code = match e.kind() {
std::io::ErrorKind::PermissionDenied => 126,
std::io::ErrorKind::NotFound => 127,
Expand Down
183 changes: 148 additions & 35 deletions tests/by-util/test_stdbuf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore cmdline dyld dylib PDEATHSIG setvbuf
// spell-checker:ignore cmdline dyld dylib PDEATHSIG setvbuf ppid

#[cfg(target_os = "linux")]
use uutests::at_and_ucmd;
Expand Down Expand Up @@ -365,73 +365,186 @@ fn test_stdbuf_non_utf8_paths() {
.stdout_is("test content for stdbuf\n");
}

// stdbuf uses spawn()+wait() (not exec()) so that the TempDir holding
// libstdbuf.so is cleaned up after the child exits. The stdbuf process
// itself therefore stays in the process table as a thin waiter; the child
// immediately execs the requested command.
// See: https://github.com/uutils/coreutils/issues/13939
#[test]
#[cfg(target_os = "linux")]
fn test_stdbuf_no_fork_regression() {
// Regression test for issue #9066: https://github.com/uutils/coreutils/issues/9066
// The original stdbuf implementation used fork+spawn which broke signal handling
// and PR_SET_PDEATHSIG. This test verifies that stdbuf uses exec() instead.
// With fork: stdbuf process would remain visible in process list
// With exec: stdbuf process is replaced by target command (GNU compatible)

fn test_stdbuf_child_execs_command() {
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;

let scene = TestScenario::new(util_name!());

// Start stdbuf with a long-running command
let mut child = Command::new(&scene.bin_path)
.args(["stdbuf", "-o0", "sleep", "3"])
let mut parent = Command::new(&scene.bin_path)
.args(["stdbuf", "-o0", "sleep", "5"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("Failed to start stdbuf");

let child_pid = child.id();
let parent_pid = parent.id();

// Poll until exec happens or timeout
let cmdline_path = format!("/proc/{child_pid}/cmdline");
let timeout = Duration::from_secs(2);
// Poll until the child process appears or timeout
let timeout = Duration::from_secs(3);
let poll_interval = Duration::from_millis(10);
let start_time = std::time::Instant::now();

let command_name = loop {
let child_comm = loop {
if start_time.elapsed() > timeout {
child.kill().ok();
panic!("TIMEOUT: Process {child_pid} did not respond within {timeout:?}");
parent.kill().ok();
panic!("TIMEOUT: child of {parent_pid} did not appear within {timeout:?}");
}

if let Ok(cmdline) = std::fs::read_to_string(&cmdline_path) {
let cmd_parts: Vec<&str> = cmdline.split('\0').collect();
let name = cmd_parts.first().map_or("", |v| v);

// Wait for exec to complete (process name changes from original binary to target)
// Handle both multicall binary (coreutils) and individual utilities (stdbuf)
if !name.contains("coreutils") && !name.contains("stdbuf") && !name.is_empty() {
break name.to_string();
}
// Find children of our stdbuf process
let found = std::fs::read_dir("/proc").ok().and_then(|entries| {
entries.flatten().find_map(|entry| {
let pid: u32 = entry.file_name().to_string_lossy().parse().ok()?;
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let mut parts = stat.splitn(5, ' ');
let _pid = parts.next();
let comm = parts
.next()
.unwrap_or("")
.trim_matches(|c| c == '(' || c == ')')
.to_string();
let _state = parts.next();
let ppid: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0);
if ppid == parent_pid && comm.contains("sleep") {
Some(comm)
} else {
None
}
})
});
if found.is_some() {
break found;
}

thread::sleep(poll_interval);

if start_time.elapsed() > timeout {
break None;
}
};

// The loop already waited for exec (no longer original binary), so this should always pass
// But keep the assertion as a safety check and clear documentation
parent.kill().ok();
parent.wait().ok();

assert!(
child_comm.is_some(),
"stdbuf should have spawned a child running 'sleep' (pid={parent_pid})"
);
assert!(
!command_name.contains("coreutils") && !command_name.contains("stdbuf"),
"REGRESSION: Process {child_pid} is still original binary (coreutils or stdbuf) - fork() used instead of exec()"
child_comm.as_deref().unwrap_or("").contains("sleep"),
"Expected child to be 'sleep', got: {child_comm:?}"
);
}

/// Verify that stdbuf does not leak temporary directories.
/// Each invocation should clean up its own tmpdir.
/// Regression test for https://github.com/uutils/coreutils/issues/13939
#[test]
#[cfg(all(target_os = "linux", not(feature = "feat_external_libstdbuf")))]
fn test_stdbuf_no_tmpdir_leak() {
use std::process::Command;

// Use a dedicated TMPDIR so we only count stdbuf-created dirs,
// not dirs created by the test harness itself.
let dedicated_tmpdir = tempfile::tempdir().unwrap();
let scene = TestScenario::new(util_name!());

for _ in 0..5 {
Command::new(&scene.bin_path)
.args(["stdbuf", "-oL", "true"])
.env("TMPDIR", dedicated_tmpdir.path())
.status()
.expect("failed to run stdbuf");
}

let leaked: Vec<_> = std::fs::read_dir(dedicated_tmpdir.path())
.unwrap()
.flatten()
.filter(|e| e.metadata().ok().map_or(false, |m| m.is_dir()))
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();

// Ensure we're running the expected target command
assert!(
command_name.contains("sleep"),
"Expected 'sleep' command at PID {child_pid}, got: {command_name}"
leaked.is_empty(),
"stdbuf leaked {n} temporary director{pl}: {leaked:?}",
n = leaked.len(),
pl = if leaked.len() == 1 { "y" } else { "ies" },
);
}

/// Verify that the temporary directory created for libstdbuf.so is private (0700)
/// and the embedded library file is owner read/write only (0600).
/// Regression test for https://github.com/uutils/coreutils/issues/13939
#[test]
#[cfg(all(target_os = "linux", not(feature = "feat_external_libstdbuf")))]
fn test_stdbuf_tmpdir_is_private() {
use std::os::unix::fs::PermissionsExt;

// Use a dedicated TMPDIR so we only observe stdbuf's directory.
let dedicated_tmpdir = tempfile::tempdir().unwrap();
let scene = TestScenario::new(util_name!());

// Use a long-running command so the tmpdir exists while we inspect it.
let mut child = std::process::Command::new(&scene.bin_path)
.args(["stdbuf", "-o0", "sleep", "5"])
.env("TMPDIR", dedicated_tmpdir.path())
.spawn()
.expect("failed to spawn stdbuf");

// Give it time to create the tmpdir
std::thread::sleep(std::time::Duration::from_millis(300));

let stdbuf_dirs: Vec<_> = std::fs::read_dir(dedicated_tmpdir.path())
.unwrap()
.flatten()
.filter(|e| e.metadata().ok().map_or(false, |m| m.is_dir()))
.map(|e| e.path())
.collect();

// Cleanup
child.kill().ok();
child.wait().ok();

assert!(
!stdbuf_dirs.is_empty(),
"expected stdbuf to create a temporary directory in {dedicated_tmpdir:?}"
);

for dir in &stdbuf_dirs {
let mode = std::fs::metadata(dir)
.expect("metadata")
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o700,
"tmpdir {dir:?} has unsafe permissions {mode:#o}, expected 0o700"
);

for entry in std::fs::read_dir(dir).expect("read_dir").flatten() {
if !entry.file_type().map_or(false, |t| t.is_file()) {
continue;
}
let file_mode = entry
.metadata()
.expect("metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(
file_mode, 0o600,
"libstdbuf at {} has unsafe permissions {file_mode:#o}, expected 0o600",
entry.path().display(),
);
}
}
}

#[cfg(unix)]
Expand Down
Loading