use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct CapturedLinkerInvocation {
pub output: Option<String>,
pub args: Vec<String>,
pub timestamp_micros: u128,
}
pub fn run() -> Result<()> {
let mut argv: Vec<String> = std::env::args().collect();
if argv.is_empty() {
anyhow::bail!("whisker-linker-shim: empty argv");
}
let _shim_path = argv.remove(0);
let linker_args = argv;
if let Some(cache_dir) = std::env::var_os("WHISKER_LINKER_CACHE_DIR") {
let cache_dir = PathBuf::from(cache_dir);
let invocation = capture(&linker_args)?;
save_invocation(&cache_dir, &invocation)
.with_context(|| format!("save to {}", cache_dir.display()))?;
}
let real_linker = std::env::var("WHISKER_REAL_LINKER").context(
"WHISKER_REAL_LINKER not set; whisker-linker-shim has nothing to forward to. \
Did you mean to install the shim in your toolchain config?",
)?;
let status = std::process::Command::new(&real_linker)
.args(&linker_args)
.status()
.with_context(|| format!("spawn {real_linker}"))?;
std::process::exit(status.code().unwrap_or(1));
}
pub fn capture(linker_args: &[String]) -> Result<CapturedLinkerInvocation> {
Ok(CapturedLinkerInvocation {
output: extract_output(linker_args),
args: linker_args.to_vec(),
timestamp_micros: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros())
.unwrap_or(0),
})
}
pub fn extract_output(args: &[String]) -> Option<String> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg == "-o" {
return iter.next().cloned();
}
}
None
}
pub fn invocation_filename(invocation: &CapturedLinkerInvocation) -> String {
let stem_for_path = invocation
.output
.as_deref()
.and_then(|s| Path::new(s).file_name())
.and_then(|n| n.to_str())
.unwrap_or("_unknown");
let safe: String = stem_for_path
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect();
format!("{}-{}.json", safe, invocation.timestamp_micros)
}
pub fn save_invocation(cache_dir: &Path, invocation: &CapturedLinkerInvocation) -> Result<()> {
std::fs::create_dir_all(cache_dir)
.with_context(|| format!("create {}", cache_dir.display()))?;
let path = cache_dir.join(invocation_filename(invocation));
let json = serde_json::to_string_pretty(invocation).context("serialize")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn unique_tempdir() -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let p = std::env::temp_dir().join(format!("whisker-linker-shim-test-{pid}-{n}"));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn extract_output_from_separated_form() {
let args = s(&["-O3", "-o", "/tmp/libfoo.dylib", "obj.o"]);
assert_eq!(extract_output(&args).as_deref(), Some("/tmp/libfoo.dylib"));
}
#[test]
fn extract_output_ignores_attached_form() {
let args = s(&["-o/tmp/libfoo.dylib", "obj.o"]);
assert_eq!(extract_output(&args), None);
}
#[test]
fn extract_output_returns_none_when_absent() {
let args = s(&["obj.o", "-shared"]);
assert_eq!(extract_output(&args), None);
}
#[test]
fn extract_output_does_not_grab_lookalike_long_flags() {
let args = s(&["-output-format=binary", "-o", "/tmp/real.so"]);
assert_eq!(extract_output(&args).as_deref(), Some("/tmp/real.so"));
}
#[test]
fn capture_preserves_full_argv() {
let args = s(&[
"-O3",
"-shared",
"-o",
"/tmp/libfoo.dylib",
"-Wl,-undefined,dynamic_lookup",
"/tmp/foo.o",
]);
let inv = capture(&args).unwrap();
assert_eq!(inv.args, args);
assert_eq!(inv.output.as_deref(), Some("/tmp/libfoo.dylib"));
assert!(inv.timestamp_micros > 0);
}
#[test]
fn capture_with_no_output_leaves_field_none() {
let inv = capture(&s(&["-shared", "obj.o"])).unwrap();
assert_eq!(inv.output, None);
}
#[test]
fn invocation_filename_uses_output_basename_and_timestamp() {
let inv = CapturedLinkerInvocation {
output: Some("/tmp/build/libfoo.dylib".into()),
args: vec![],
timestamp_micros: 42,
};
assert_eq!(invocation_filename(&inv), "libfoo.dylib-42.json");
}
#[test]
fn invocation_filename_handles_anonymous_invocation() {
let inv = CapturedLinkerInvocation {
output: None,
args: vec![],
timestamp_micros: 7,
};
assert_eq!(invocation_filename(&inv), "_unknown-7.json");
}
#[test]
fn invocation_filename_sanitises_weird_characters() {
let inv = CapturedLinkerInvocation {
output: Some("/tmp/foo bar/lib weird?name.so".into()),
args: vec![],
timestamp_micros: 1,
};
assert_eq!(invocation_filename(&inv), "lib_weird_name.so-1.json");
}
#[test]
fn save_invocation_writes_and_round_trips() {
let dir = unique_tempdir();
let inv = CapturedLinkerInvocation {
output: Some("/tmp/libfoo.dylib".into()),
args: s(&["-shared", "-o", "/tmp/libfoo.dylib", "foo.o"]),
timestamp_micros: 12345,
};
save_invocation(&dir, &inv).expect("save");
let path = dir.join(invocation_filename(&inv));
assert!(path.is_file());
let body = std::fs::read_to_string(&path).unwrap();
let parsed: CapturedLinkerInvocation = serde_json::from_str(&body).unwrap();
assert_eq!(parsed, inv);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_invocation_creates_the_cache_dir_if_missing() {
let dir = unique_tempdir().join("nested/path");
assert!(!dir.exists());
let inv = CapturedLinkerInvocation {
output: Some("/tmp/lib.dylib".into()),
args: vec![],
timestamp_micros: 1,
};
save_invocation(&dir, &inv).expect("save");
assert!(dir.is_dir());
let mut to_remove = dir;
for _ in 0..3 {
to_remove.pop();
}
let _ = std::fs::remove_dir_all(&to_remove);
}
}