use std::collections::{HashMap, HashSet};
use std::fs::{self, File};
use std::io;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use super::color;
use super::elf::LibcFamily;
pub(crate) fn loaded_libraries(
target: &Path,
args: &[String],
seconds: u64,
family: Option<LibcFamily>,
tree: &Path,
library_path: &[PathBuf],
loader: Option<&Path>,
) -> io::Result<(Vec<PathBuf>, Vec<PathBuf>)> {
let tree = tree.canonicalize()?;
let target = target.canonicalize()?;
let scratch = super::private_temp_dir("trace")?;
let result = trace(
&target,
args,
seconds,
family,
&tree,
library_path,
loader,
&scratch,
);
let _ = fs::remove_dir_all(&scratch);
let opened: Vec<PathBuf> = result?
.iter()
.filter(|p| p.is_absolute())
.cloned()
.collect();
let mut libs: Vec<PathBuf> = opened
.iter()
.filter(|p| !p.starts_with(&tree))
.filter(|p| p.is_file())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(".so"))
})
.cloned()
.collect();
libs.sort();
libs.dedup();
Ok((libs, opened))
}
#[allow(clippy::too_many_arguments)]
fn trace(
target: &Path,
args: &[String],
seconds: u64,
family: Option<LibcFamily>,
tree: &Path,
library_path: &[PathBuf],
loader: Option<&Path>,
scratch: &Path,
) -> io::Result<Vec<PathBuf>> {
let home = scratch.join("home");
let logs = scratch.join("logs");
fs::create_dir(&home)?;
fs::create_dir(&logs)?;
let strace_log = logs.join("strace");
let musl = family == Some(LibcFamily::Musl);
let mut cmd = if musl {
let mut cmd = Command::new("strace");
cmd.args(["-f", "-qq", "-e", "trace=execve,open,openat", "-o"])
.arg(&strace_log);
cmd.args(loader).arg(target);
cmd
} else {
let mut cmd = Command::new(loader.unwrap_or(target));
if loader.is_some() {
cmd.arg(target);
}
cmd.env("LD_DEBUG", "libs,files")
.env("LD_DEBUG_OUTPUT", logs.join("ld"));
cmd
};
let library_path = library_path
.iter()
.map(|d| d.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(":");
cmd.args(args)
.current_dir(&home)
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_DATA_HOME", home.join(".local/share"))
.env("XDG_STATE_HOME", home.join(".local/state"))
.env("XDG_CACHE_HOME", home.join(".cache"))
.env("LD_LIBRARY_PATH", library_path);
let stderr_log = scratch.join("stderr");
let failed = match run_for(&mut cmd, seconds, File::create(&stderr_log)?) {
Ok(failed) => failed,
Err(e) if musl && e.kind() == io::ErrorKind::NotFound => {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"tracing a musl target needs strace, which is not on PATH",
));
}
Err(e) => {
return Err(io::Error::new(
e.kind(),
format!("running {}: {e}", target.display()),
));
}
};
if let Some(status) = failed {
let stderr = fs::read_to_string(&stderr_log).unwrap_or_default();
let lines: Vec<&str> = stderr.lines().collect();
eprintln!(
" {} the run ended by itself with {status}; it may not have loaded \
everything it does in use",
color::bold_red("warning:"),
);
for line in &lines[lines.len().saturating_sub(5)..] {
eprintln!(" {line}");
}
}
let ours = |program: &Path| program.starts_with(tree) || Some(program) == loader;
if musl {
let text = fs::read_to_string(&strace_log).unwrap_or_default();
return Ok(parse_strace(&text, ours));
}
let mut found = Vec::new();
for entry in fs::read_dir(&logs)?.flatten() {
let Ok(text) = fs::read_to_string(entry.path()) else {
continue;
};
let (libs, programs) = parse_ld_debug(&text);
let foreign = !programs.is_empty()
&& programs
.iter()
.all(|p| locate_program(p, &home).is_some_and(|p| !ours(&p)));
if !foreign {
found.extend(libs);
}
}
Ok(found)
}
fn parse_ld_debug(text: &str) -> (Vec<PathBuf>, Vec<&str>) {
let mut found = Vec::new();
let mut programs = Vec::new();
for line in text.lines() {
let line = line
.trim_start()
.trim_start_matches(|c: char| c.is_ascii_digit() || c == ':')
.trim_start();
if let Some(program) = line.strip_prefix("initialize program: ") {
programs.push(program.trim());
continue;
}
let path = line.strip_prefix("calling init: ").or_else(|| {
line.strip_prefix("file=")
.and_then(|rest| rest.split_once(" [").map(|(p, _)| p))
.filter(|_| line.contains("generating link map"))
});
if let Some(path) = path {
found.push(PathBuf::from(path.trim()));
}
}
(found, programs)
}
fn locate_program(argv0: &str, cwd: &Path) -> Option<PathBuf> {
let path = Path::new(argv0);
if path.is_absolute() {
return path.canonicalize().ok();
}
if argv0.contains('/') {
return cwd.join(path).canonicalize().ok();
}
let search = std::env::var_os("PATH")?;
std::env::split_paths(&search)
.map(|dir| dir.join(argv0))
.find(|candidate| candidate.is_file())
.and_then(|found| found.canonicalize().ok())
}
fn parse_strace(text: &str, ours: impl Fn(&Path) -> bool) -> Vec<PathBuf> {
let mut pending: HashMap<&str, &str> = HashMap::new();
let mut foreign: HashSet<&str> = HashSet::new();
let mut found = Vec::new();
for line in text.lines() {
let Some((pid, rest)) = line.trim_start().split_once(char::is_whitespace) else {
continue;
};
let rest = rest.trim_start();
let call = if let Some(head) = rest.strip_suffix("<unfinished ...>") {
pending.insert(pid, head);
continue;
} else if rest.starts_with("<...") {
let (Some(head), Some((_, tail))) = (pending.remove(pid), rest.split_once("resumed>"))
else {
continue;
};
format!("{head}{tail}")
} else {
rest.to_string()
};
let Some((head, rc)) = call.trim_end().rsplit_once(" = ") else {
continue;
};
let succeeded = rc
.split_whitespace()
.next()
.and_then(|n| n.parse::<i64>().ok())
.is_some_and(|n| n >= 0);
if !succeeded {
continue;
}
let Some(path) = head
.split_once('"')
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(path, _)| Path::new(path))
else {
continue;
};
if head.starts_with("execve(") {
let program = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let elsewhere = path.is_absolute() && !ours(&program);
if elsewhere {
foreign.insert(pid);
} else {
foreign.remove(pid);
}
continue;
}
if !foreign.contains(pid) {
found.push(path.to_path_buf());
}
}
found
}
fn run_for(cmd: &mut Command, seconds: u64, stderr: File) -> io::Result<Option<ExitStatus>> {
use rustix::process::{Pid, Signal, kill_process_group, test_kill_process_group};
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(stderr)
.process_group(0)
.spawn()?;
let group = Pid::from_raw(child.id() as i32);
let deadline = Instant::now() + Duration::from_secs(seconds);
let mut failed = None;
loop {
if let Some(status) = child.try_wait()? {
if !status.success() {
failed = Some(status);
}
break;
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
if let Some(group) = group {
let _ = kill_process_group(group, Signal::TERM);
let grace = Instant::now() + Duration::from_secs(2);
while (child.try_wait()?.is_none() || test_kill_process_group(group).is_ok())
&& Instant::now() < grace
{
std::thread::sleep(Duration::from_millis(50));
}
let _ = kill_process_group(group, Signal::KILL);
}
let _ = child.wait();
Ok(failed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_split_strace_call_is_joined_and_host_programs_are_left_out() {
let log = "\
100 execve(\"/tree/bin/app\", [\"app\"], 0x1 /* 3 vars */) = 0
100 open(\"/usr/lib/libz.so.1\", O_RDONLY|O_LARGEFILE|O_CLOEXEC <unfinished ...>
101 execve(\"/host/bin/helper\", [\"helper\"], 0x2 /* 3 vars */ <unfinished ...>
100 <... open resumed>) = 3
101 <... execve resumed>) = 0
101 open(\"/usr/lib/libhelper.so.1\", O_RDONLY) = 3
100 open(\"/usr/lib/libmissing.so.1\", O_RDONLY) = -1 ENOENT (No such file or directory)
";
let found = parse_strace(log, |p| p.starts_with("/tree"));
assert_eq!(found, vec![PathBuf::from("/usr/lib/libz.so.1")]);
}
#[test]
fn a_log_names_its_programs() {
let log = " 7:\tcalling init: /usr/lib/libz.so.1\n\
\x20 7:\tinitialize program: /tree/bin/app\n";
let (libs, programs) = parse_ld_debug(log);
assert_eq!(libs, vec![PathBuf::from("/usr/lib/libz.so.1")]);
assert_eq!(programs, vec!["/tree/bin/app"]);
}
}