use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serde::{Deserialize, Serialize};
const REPLY_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InverseSearchRequest {
pub path: PathBuf,
pub line: u32,
pub character: u32,
pub token: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InverseSearchResponse {
pub accepted: bool,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
Unix,
Tcp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Advertisement {
pub pid: u32,
pub transport: Transport,
pub address: String,
pub token: String,
pub roots: Vec<PathBuf>,
}
#[derive(Debug)]
pub enum IpcError {
NoServer,
NoServerForFile(PathBuf),
Io(std::io::Error),
}
impl std::fmt::Display for IpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoServer => write!(
f,
"no badness language server is listening for inverse search \
(is your editor running one, and does it support window/showDocument?)"
),
Self::NoServerForFile(path) => write!(
f,
"no listening badness language server has {} in its workspace",
path.display()
),
Self::Io(err) => write!(f, "could not read the IPC directory: {err}"),
}
}
}
impl std::error::Error for IpcError {}
pub fn ipc_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("BADNESS_IPC_DIR") {
return PathBuf::from(dir);
}
let base = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
base.join(format!("badness-{}", user_scope()))
}
fn user_scope() -> String {
#[cfg(unix)]
{
unsafe { libc_getuid() }.to_string()
}
#[cfg(not(unix))]
{
"user".to_owned()
}
}
#[cfg(unix)]
unsafe extern "C" {
#[link_name = "getuid"]
fn libc_getuid() -> u32;
}
fn ensure_dir(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn token() -> String {
use std::hash::{BuildHasher, Hasher, RandomState};
let mut hi = RandomState::new().build_hasher();
let mut lo = RandomState::new().build_hasher();
let salt = std::time::Instant::now();
hi.write_u32(std::process::id());
hi.write_usize(&salt as *const _ as usize);
lo.write_u32(std::process::id());
lo.write_usize(format!("{salt:?}").len());
format!("{:016x}{:016x}", hi.finish(), lo.finish())
}
pub struct Responder {
stream: Option<Stream>,
}
impl Responder {
pub fn accept(self) {
self.answer(InverseSearchResponse {
accepted: true,
reason: None,
});
}
pub fn reject(self, reason: &str) {
self.answer(InverseSearchResponse {
accepted: false,
reason: Some(reason.to_owned()),
});
}
fn answer(mut self, response: InverseSearchResponse) {
let Some(stream) = self.stream.take() else {
return;
};
let mut out = BufWriter::new(stream);
if serde_json::to_writer(&mut out, &response).is_ok() {
let _ = out.write_all(b"\n");
let _ = out.flush();
}
}
}
#[cfg(unix)]
use std::os::unix::net::{UnixListener as SysListener, UnixStream as SysStream};
#[cfg(unix)]
type Stream = SysStream;
#[cfg(not(unix))]
type Stream = std::net::TcpStream;
#[cfg(unix)]
type Acceptor = SysListener;
#[cfg(not(unix))]
type Acceptor = std::net::TcpListener;
#[cfg(unix)]
const MAX_SOCKET_PATH: usize = 104;
#[cfg(unix)]
fn bind(dir: &Path) -> std::io::Result<(Acceptor, Transport, String)> {
let path = dir.join(format!("{}.sock", std::process::id()));
if path.as_os_str().len() > MAX_SOCKET_PATH {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"the socket path {} is {} bytes, over the {MAX_SOCKET_PATH}-byte limit \
for a Unix socket; set `forwardSearch.ipcDir` to a shorter directory",
path.display(),
path.as_os_str().len(),
),
));
}
let _ = std::fs::remove_file(&path);
let listener = SysListener::bind(&path)?;
Ok((
listener,
Transport::Unix,
path.to_string_lossy().into_owned(),
))
}
#[cfg(not(unix))]
fn bind(_dir: &Path) -> std::io::Result<(Acceptor, Transport, String)> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
let address = listener.local_addr()?.to_string();
Ok((listener, Transport::Tcp, address))
}
fn connect(ad: &Advertisement) -> std::io::Result<Stream> {
match ad.transport {
#[cfg(unix)]
Transport::Unix => SysStream::connect(&ad.address),
#[cfg(not(unix))]
Transport::Unix => Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"unix sockets are unavailable on this platform",
)),
#[cfg(unix)]
Transport::Tcp => Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"this server advertised TCP, which this platform does not dial",
)),
#[cfg(not(unix))]
Transport::Tcp => std::net::TcpStream::connect(&ad.address),
}
}
pub struct Listener {
acceptor: Acceptor,
advertisement: PathBuf,
address: String,
transport: Transport,
token: String,
shutdown: AtomicBool,
}
impl Listener {
pub fn bind_in(dir: &Path, roots: Vec<PathBuf>) -> Option<Self> {
if let Err(err) = ensure_dir(dir) {
log::warn!(
"inverse search: cannot use the IPC directory {}: {err}",
dir.display()
);
return None;
}
let (acceptor, transport, address) = bind(dir)
.inspect_err(|err| log::warn!("inverse search: cannot bind a socket: {err}"))
.ok()?;
let token = token();
let advertisement = dir.join(format!("{}.json", std::process::id()));
let listener = Self {
acceptor,
advertisement,
address,
transport,
token,
shutdown: AtomicBool::new(false),
};
listener.publish(roots).ok()?;
Some(listener)
}
fn publish(&self, roots: Vec<PathBuf>) -> std::io::Result<()> {
let ad = Advertisement {
pid: std::process::id(),
transport: self.transport,
address: self.address.clone(),
token: self.token.clone(),
roots,
};
let body = serde_json::to_vec(&ad).map_err(std::io::Error::other)?;
std::fs::write(&self.advertisement, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.advertisement, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn accept_one(&self) -> Option<(InverseSearchRequest, Responder)> {
loop {
let stream = self.acceptor.accept().ok()?.0;
if self.shutdown.load(Ordering::SeqCst) {
return None;
}
let Some(request) = read_request(&stream) else {
continue;
};
if request.token != self.token {
log::warn!("inverse search: rejecting a request with a bad token");
continue;
}
return Some((
request,
Responder {
stream: Some(stream),
},
));
}
}
pub fn wake(&self) {
self.shutdown.store(true, Ordering::SeqCst);
let ad = Advertisement {
pid: std::process::id(),
transport: self.transport,
address: self.address.clone(),
token: self.token.clone(),
roots: Vec::new(),
};
let _ = connect(&ad);
}
}
impl Drop for Listener {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.advertisement);
if self.transport == Transport::Unix {
let _ = std::fs::remove_file(&self.address);
}
}
}
fn read_request(stream: &Stream) -> Option<InverseSearchRequest> {
let mut line = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut line).ok()?;
serde_json::from_str(&line).ok()
}
pub fn send_inverse_search_in(
dir: &Path,
path: &Path,
line: u32,
character: u32,
) -> Result<(), IpcError> {
let mut candidates = read_advertisements(dir)?;
if candidates.is_empty() {
return Err(IpcError::NoServer);
}
candidates.sort_by_key(|(_, ad)| {
let score = ad
.roots
.iter()
.filter(|root| path.starts_with(root))
.map(|root| root.components().count())
.max();
(std::cmp::Reverse(score), ad.pid)
});
for (file, ad) in &candidates {
match deliver(ad, path, line, character) {
Ok(true) => return Ok(()),
Ok(false) => continue,
Err(_) => {
let _ = std::fs::remove_file(file);
}
}
}
Err(IpcError::NoServerForFile(path.to_path_buf()))
}
pub fn send_inverse_search(path: &Path, line: u32, character: u32) -> Result<(), IpcError> {
send_inverse_search_in(&ipc_dir(), path, line, character)
}
fn read_advertisements(dir: &Path) -> Result<Vec<(PathBuf, Advertisement)>, IpcError> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Err(IpcError::NoServer),
Err(err) => return Err(IpcError::Io(err)),
};
Ok(entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.filter(|path| trustworthy(path))
.filter_map(|path| {
let body = std::fs::read(&path).ok()?;
Some((path, serde_json::from_slice(&body).ok()?))
})
.collect())
}
fn trustworthy(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if meta.uid() != unsafe { libc_getuid() } {
return false;
}
meta.permissions().mode() & 0o077 == 0
}
#[cfg(not(unix))]
{
let _ = path;
true
}
}
fn deliver(ad: &Advertisement, path: &Path, line: u32, character: u32) -> std::io::Result<bool> {
let stream = connect(ad)?;
stream.set_read_timeout(Some(REPLY_TIMEOUT))?;
stream.set_write_timeout(Some(REPLY_TIMEOUT))?;
let request = InverseSearchRequest {
path: path.to_path_buf(),
line,
character,
token: ad.token.clone(),
};
{
let mut out = BufWriter::new(&stream);
serde_json::to_writer(&mut out, &request).map_err(std::io::Error::other)?;
out.write_all(b"\n")?;
out.flush()?;
}
let mut line = String::new();
BufReader::new(&stream).read_line(&mut line)?;
let response: InverseSearchResponse =
serde_json::from_str(&line).map_err(std::io::Error::other)?;
Ok(response.accepted)
}
#[cfg(test)]
mod tests {
use super::*;
fn listener(dir: &Path, roots: Vec<PathBuf>) -> Listener {
Listener::bind_in(dir, roots).expect("bind an inverse-search socket")
}
#[test]
fn request_round_trips_and_is_acked() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ipc");
let listener = listener(&dir, vec![]);
let file = tmp.path().join("main.tex");
let client = {
let (dir, file) = (dir.clone(), file.clone());
std::thread::spawn(move || send_inverse_search_in(&dir, &file, 42, 7))
};
let (request, responder) = listener.accept_one().expect("a request");
assert_eq!(request.path, file);
assert_eq!(request.line, 42);
assert_eq!(request.character, 7);
assert_eq!(request.token, listener.token);
responder.accept();
client.join().unwrap().expect("delivered");
}
#[test]
fn a_rejecting_server_does_not_swallow_the_request() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ipc");
let listener = listener(&dir, vec![]);
let file = tmp.path().join("main.tex");
let client = {
let (dir, file) = (dir.clone(), file.clone());
std::thread::spawn(move || send_inverse_search_in(&dir, &file, 1, 0))
};
let (_, responder) = listener.accept_one().expect("a request");
responder.reject("not mine");
let err = client.join().unwrap().expect_err("nobody claimed the file");
assert!(matches!(err, IpcError::NoServerForFile(_)), "{err:?}");
}
#[test]
fn a_server_whose_roots_exclude_the_file_is_tried_last() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ipc");
let owner = listener(&dir, vec![tmp.path().join("owned")]);
let stranger = Advertisement {
pid: std::process::id() + 1,
transport: owner.transport,
address: owner.address.clone(),
token: owner.token.clone(),
roots: vec![tmp.path().join("elsewhere")],
};
std::fs::write(
dir.join(format!("{}.json", stranger.pid)),
serde_json::to_vec(&stranger).unwrap(),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
dir.join(format!("{}.json", stranger.pid)),
std::fs::Permissions::from_mode(0o600),
)
.unwrap();
}
let file = tmp.path().join("owned").join("ch1.tex");
let client = {
let (dir, file) = (dir.clone(), file.clone());
std::thread::spawn(move || send_inverse_search_in(&dir, &file, 1, 0))
};
let (request, responder) = owner.accept_one().expect("a request");
assert_eq!(request.token, owner.token);
responder.accept();
client.join().unwrap().expect("delivered");
}
#[test]
fn a_stale_advertisement_is_unlinked() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ipc");
ensure_dir(&dir).unwrap();
let stale = dir.join("999999.json");
let ad = Advertisement {
pid: 999_999,
transport: if cfg!(unix) {
Transport::Unix
} else {
Transport::Tcp
},
address: if cfg!(unix) {
dir.join("999999.sock").to_string_lossy().into_owned()
} else {
"127.0.0.1:9".to_owned()
},
token: "deadbeef".to_owned(),
roots: vec![],
};
std::fs::write(&stale, serde_json::to_vec(&ad).unwrap()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stale, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let err = send_inverse_search_in(&dir, &tmp.path().join("main.tex"), 1, 0)
.expect_err("the advertised server is not there");
assert!(matches!(err, IpcError::NoServerForFile(_)), "{err:?}");
assert!(!stale.exists(), "a stale advertisement must be unlinked");
}
#[cfg(unix)]
#[test]
fn an_over_long_socket_path_is_diagnosed() {
let tmp = tempfile::tempdir().unwrap();
let mut dir = tmp.path().to_path_buf();
while dir.as_os_str().len() <= MAX_SOCKET_PATH {
dir.push("deeply-nested-directory-name");
}
let err = bind(&dir).expect_err("this path cannot hold a socket");
let message = err.to_string();
assert!(message.contains("ipcDir"), "{message}");
assert!(message.contains(&MAX_SOCKET_PATH.to_string()), "{message}");
}
#[test]
fn an_empty_directory_reports_no_server() {
let tmp = tempfile::tempdir().unwrap();
for dir in [tmp.path().to_path_buf(), tmp.path().join("never-used")] {
let err = send_inverse_search_in(&dir, &tmp.path().join("main.tex"), 1, 0)
.expect_err("nothing is listening");
assert!(matches!(err, IpcError::NoServer), "{err:?}");
}
}
#[test]
fn wake_unblocks_accept() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ipc");
let listener = std::sync::Arc::new(listener(&dir, vec![]));
let parked = {
let listener = std::sync::Arc::clone(&listener);
std::thread::spawn(move || listener.accept_one().is_none())
};
listener.wake();
assert!(parked.join().unwrap(), "wake must end the accept loop");
}
#[test]
fn drop_unlinks_the_advertisement_and_socket() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ipc");
let (advertisement, address) = {
let listener = listener(&dir, vec![]);
(listener.advertisement.clone(), listener.address.clone())
};
assert!(!advertisement.exists());
if cfg!(unix) {
assert!(!Path::new(&address).exists());
}
}
#[test]
fn advertisement_round_trips() {
let ad = Advertisement {
pid: 7,
transport: Transport::Unix,
address: "/run/user/1000/badness-1000/7.sock".to_owned(),
token: "0123456789abcdef0123456789abcdef".to_owned(),
roots: vec![PathBuf::from("/home/u/paper")],
};
let json = serde_json::to_string(&ad).unwrap();
assert!(json.contains("\"transport\":\"unix\""), "{json}");
assert_eq!(
serde_json::from_str::<Advertisement>(&json).unwrap(),
ad,
"the advertisement is a private protocol, but both ends must agree \
across a rolling upgrade"
);
}
#[test]
fn tokens_differ_between_calls() {
let (a, b) = (token(), token());
assert_eq!(a.len(), 32);
assert_ne!(a, b, "a predictable token would not guard a loopback port");
}
}