use std::collections::BTreeSet;
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::{fmt, fs, thread};
const RUNTIME_DIRECTORY: &str = "XDG_RUNTIME_DIR";
const SOCKET: &str = "beady-eye/changes.sock";
const LONGEST_MESSAGE: usize = 512;
const OWNER_ONLY: u32 = 0o600;
const ONLY_THIS_USER_MAY_ENTER: u32 = 0o700;
const A_GROUP_MAY_TAKE_NAMES: u32 = 0o030;
const ANYBODY_MAY_TAKE_NAMES: u32 = 0o003;
const NAMES_STAY_THEIR_OWNERS: u32 = 0o1000;
const THE_SYSTEM: u32 = 0;
#[derive(Debug, PartialEq, Eq)]
pub enum Answer {
Watched(String),
Unwatched(String),
Malformed,
}
impl fmt::Display for Answer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Answer::Watched(project) => write!(f, "ok {project}"),
Answer::Unwatched(project) => write!(f, "unknown {project}"),
Answer::Malformed => write!(f, "malformed"),
}
}
}
#[derive(Clone, Default)]
pub struct Reported {
projects: Arc<Mutex<Arc<BTreeSet<String>>>>,
}
impl Reported {
pub fn watching<I: IntoIterator<Item = String>>(projects: I) -> Self {
Self {
projects: Arc::new(Mutex::new(Arc::new(projects.into_iter().collect()))),
}
}
pub fn now_watching<I: IntoIterator<Item = String>>(&self, projects: I) {
*self.held() = Arc::new(projects.into_iter().collect());
}
pub fn take(&self, message: &str) -> Answer {
let named = message.trim();
if named.is_empty() || named.len() > LONGEST_MESSAGE {
return Answer::Malformed;
}
let watching = Arc::clone(&self.held());
if watching.contains(named) {
Answer::Watched(named.to_string())
} else {
Answer::Unwatched(named.to_string())
}
}
fn held(&self) -> MutexGuard<'_, Arc<BTreeSet<String>>> {
self.projects.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[derive(Debug)]
pub enum Refused {
NoRuntimeDirectory,
AlreadyListening(PathBuf),
NotASocket(PathBuf),
NameOthersMayTake(PathBuf),
Unopenable(PathBuf, std::io::Error),
}
impl fmt::Display for Refused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"nothing can tell bdi a project changed, so every project is polled: "
)?;
match self {
Refused::NoRuntimeDirectory => {
write!(
f,
"this session has no {RUNTIME_DIRECTORY} to put the socket in — name a path with --socket, or with socket under [changes] in the config, and bdi listens there"
)
}
Refused::AlreadyListening(at) => {
write!(
f,
"another bdi is listening on {}; ss -lxp or lsof -U names which — close it and restart bdi to get the channel",
at.display()
)
}
Refused::NotASocket(at) => {
write!(
f,
"{} is not a socket and bdi will not take it — name another path with --socket, or with socket under [changes] in the config",
at.display()
)
}
Refused::NameOthersMayTake(directory) => {
write!(
f,
"another user may take a name in {} — name a socket path with no such directory above it, with --socket or with socket under [changes] in the config",
directory.display()
)
}
Refused::Unopenable(at, why) => {
write!(f, "{} could not be opened ({why})", at.display())
}
}
}
}
pub struct Socket {
at: PathBuf,
bound: Option<File>,
}
type File = (u64, u64);
fn file_at(named: &Path) -> Option<File> {
fs::symlink_metadata(named)
.ok()
.map(|what| (what.dev(), what.ino()))
}
impl Drop for Socket {
fn drop(&mut self) {
if file_at(&self.at) == self.bound {
let _ = fs::remove_file(&self.at);
}
}
}
pub fn where_writers_find_bdi(told: Option<PathBuf>) -> Option<PathBuf> {
told.or_else(|| {
under(
std::env::var_os(RUNTIME_DIRECTORY)
.map(PathBuf::from)
.as_deref(),
)
})
}
fn under(runtime_directory: Option<&Path>) -> Option<PathBuf> {
runtime_directory.map(|dir| dir.join(SOCKET))
}
pub fn listen(
at: Option<PathBuf>,
reported: &Reported,
changed: Sender<String>,
) -> Result<Socket, Refused> {
let at = at.ok_or(Refused::NoRuntimeDirectory)?;
let listener = bind(&at)?;
let bound = file_at(&at);
let reported = reported.clone();
thread::spawn(move || accept(&listener, &reported, &changed));
Ok(Socket { at, bound })
}
fn bind(at: &Path) -> Result<UnixListener, Refused> {
let directory = directory_holding(at);
fs::DirBuilder::new()
.recursive(true)
.mode(ONLY_THIS_USER_MAY_ENTER)
.create(directory)
.map_err(|why| Refused::Unopenable(at.to_path_buf(), why))?;
only_this_user_may_take_a_name_under(directory)?;
let listener = match UnixListener::bind(at) {
Ok(listener) => listener,
Err(taken) if taken.kind() == ErrorKind::AddrInUse => reclaim(at)?,
Err(why) => return Err(Refused::Unopenable(at.to_path_buf(), why)),
};
fs::set_permissions(at, fs::Permissions::from_mode(OWNER_ONLY))
.map_err(|why| Refused::Unopenable(at.to_path_buf(), why))?;
Ok(listener)
}
fn directory_holding(at: &Path) -> &Path {
match at.parent() {
Some(directory) if !directory.as_os_str().is_empty() => directory,
_ => Path::new("."),
}
}
fn only_this_user_may_take_a_name_under(under: &Path) -> Result<(), Refused> {
let unreadable = |directory: &Path| {
let directory = directory.to_path_buf();
move |why| Refused::Unopenable(directory, why)
};
let resolved = fs::canonicalize(under).map_err(unreadable(under))?;
let this_user = this_user();
for directory in directories_on(under).chain(directories_on(&resolved)) {
let what = fs::metadata(directory).map_err(unreadable(directory))?;
if others_may_take_a_name_in(what.permissions().mode(), what.uid(), this_user) {
return Err(Refused::NameOthersMayTake(directory.to_path_buf()));
}
}
Ok(())
}
fn directories_on(way: &Path) -> impl Iterator<Item = &Path> {
way.ancestors()
.enumerate()
.filter(|(above, directory)| *above == 0 || directory.parent().is_some())
.map(|(_, directory)| directory)
}
fn this_user() -> u32 {
unsafe { libc::geteuid() }
}
fn others_may_take_a_name_in(how: u32, owner: u32, this_user: u32) -> bool {
if owner != this_user && owner != THE_SYSTEM {
return true;
}
let anybody_else = how & A_GROUP_MAY_TAKE_NAMES == A_GROUP_MAY_TAKE_NAMES
|| how & ANYBODY_MAY_TAKE_NAMES == ANYBODY_MAY_TAKE_NAMES;
anybody_else && how & NAMES_STAY_THEIR_OWNERS == 0
}
fn reclaim(at: &Path) -> Result<UnixListener, Refused> {
if UnixStream::connect(at).is_ok() {
return Err(Refused::AlreadyListening(at.to_path_buf()));
}
if !is_a_socket(at) {
return Err(Refused::NotASocket(at.to_path_buf()));
}
fs::remove_file(at).map_err(|why| Refused::Unopenable(at.to_path_buf(), why))?;
UnixListener::bind(at).map_err(|why| Refused::Unopenable(at.to_path_buf(), why))
}
fn is_a_socket(at: &Path) -> bool {
fs::symlink_metadata(at).is_ok_and(|what| what.file_type().is_socket())
}
fn accept(listener: &UnixListener, reported: &Reported, changed: &Sender<String>) {
for writer in listener.incoming() {
let Ok(writer) = writer else { return };
let (reported, changed) = (reported.clone(), changed.clone());
thread::spawn(move || hear(writer, &reported, &changed));
}
}
fn hear(writer: UnixStream, reported: &Reported, changed: &Sender<String>) {
let Ok(mut answering) = writer.try_clone() else {
return;
};
let mut reading = BufReader::new(writer);
let mut line = Vec::new();
loop {
line.clear();
let room = LONGEST_MESSAGE as u64 + 1;
match (&mut reading).take(room).read_until(b'\n', &mut line) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
let unended = line.len() > LONGEST_MESSAGE;
let answer = match std::str::from_utf8(&line) {
Ok(message) if !unended => reported.take(message),
_ => Answer::Malformed,
};
if let Answer::Watched(project) = &answer {
if changed.send(project.clone()).is_err() {
return;
}
}
if writeln!(answering, "{answer}").is_err() || unended {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::any::Any;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::Shutdown;
use std::os::unix::net::{UnixListener, UnixStream};
use std::panic::AssertUnwindSafe;
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
const A_MOMENT: Duration = Duration::from_secs(5);
fn watching<const N: usize>(projects: [&str; N]) -> Reported {
Reported::watching(projects.map(str::to_string))
}
fn a_socket_path(named: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bdi-{named}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a directory to put the socket in");
dir.join("beady-eye").join("changes.sock")
}
fn a_socket_in_a_directory_moded(named: &str, how: u32) -> PathBuf {
let around = std::env::temp_dir().join(format!("bdi-{named}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&around);
std::fs::create_dir_all(&around).expect("a directory to keep the test's own in");
std::fs::set_permissions(
&around,
std::fs::Permissions::from_mode(ONLY_THIS_USER_MAY_ENTER),
)
.expect("and nobody else may enter it");
let told = around.join("told");
std::fs::create_dir_all(&told).expect("a directory to put the socket in");
std::fs::set_permissions(&told, std::fs::Permissions::from_mode(how))
.expect("set as the test means it rather than as umask left it");
told.join("changes.sock")
}
fn open(at: &Path, reported: &Reported) -> (Socket, Receiver<String>) {
let (changed, changes) = mpsc::channel();
let socket = listen(Some(at.to_path_buf()), reported, changed).expect("the socket opens");
(socket, changes)
}
fn connect(at: &Path) -> (UnixStream, BufReader<UnixStream>) {
let writing = UnixStream::connect(at).expect("bdi is listening");
let reading = BufReader::new(writing.try_clone().expect("both ends of the stream"));
(writing, reading)
}
#[track_caller]
fn say(at: &Path, exchanges: &[(&str, &str)]) {
let (mut writing, mut reading) = connect(at);
let mut answered = Vec::new();
for (line, expected) in exchanges {
let mut said = String::new();
let heard = write!(writing, "{line}").and_then(|()| reading.read_line(&mut said));
assert!(
matches!(heard, Ok(1..)),
"bdi let the writer go at {line:?}, after answering {answered:?}"
);
assert_eq!(said.trim_end(), *expected, "bdi's answer to {line:?}");
answered.push(said.trim_end().to_string());
}
}
fn message_of(panic: Box<dyn Any + Send>) -> String {
panic
.downcast_ref::<String>()
.cloned()
.or_else(|| panic.downcast_ref::<&str>().map(|said| said.to_string()))
.expect("the failure carried a message")
}
#[test]
fn a_bdi_that_lets_a_writer_go_is_reported_by_the_message_it_never_answered() {
let at = a_socket_path("lets-the-writer-go");
std::fs::create_dir_all(at.parent().expect("the socket is in a directory"))
.expect("a directory to put the socket in");
let listener = UnixListener::bind(&at).expect("a stand-in for bdi");
let stand_in = thread::spawn(move || {
let (stream, _) = listener.accept().expect("the writer connects");
let mut reading = BufReader::new(stream.try_clone().expect("both ends of the stream"));
let mut line = String::new();
reading.read_line(&mut line).expect("the first message");
writeln!(&stream, "ok arkham").expect("the answer goes back");
line.clear();
reading.read_line(&mut line).expect("the second message");
});
let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
say(&at, &[("arkham\n", "ok arkham"), ("ferry\n", "ok ferry")]);
}));
stand_in.join().expect("the stand-in ran to its end");
let red = message_of(outcome.expect_err("say noticed bdi had gone"));
assert!(
red.contains("\"ferry\\n\""),
"the red names the message bdi never answered: {red}"
);
assert!(
red.contains("\"ok arkham\""),
"the red says how far the conversation got: {red}"
);
}
#[test]
fn a_message_naming_a_watched_project_is_taken() {
let reported = watching(["arkham", "ferry"]);
assert_eq!(
reported.take("arkham"),
Answer::Watched("arkham".to_string())
);
}
#[test]
fn a_name_is_taken_without_the_whitespace_around_it() {
let reported = watching(["arkham", "ferry"]);
assert_eq!(
reported.take(" arkham \n"),
Answer::Watched("arkham".to_string())
);
}
#[test]
fn a_message_naming_nothing_bdi_watches_is_said_back_and_nothing_else() {
let reported = watching(["arkham", "ferry"]);
assert_eq!(
reported.take("ghost"),
Answer::Unwatched("ghost".to_string())
);
}
#[test]
fn the_projects_written_are_the_ones_taken_from_then_on() {
let reported = watching(["arkham"]);
reported.now_watching(["ferry".to_string()]);
assert_eq!(reported.take("ferry"), Answer::Watched("ferry".to_string()));
assert_eq!(
reported.take("arkham"),
Answer::Unwatched("arkham".to_string())
);
}
#[test]
fn a_clone_taken_before_the_write_takes_against_what_was_written() {
let reported = watching(["arkham"]);
let held_by_a_connection = reported.clone();
reported.now_watching(["ferry".to_string()]);
assert_eq!(
held_by_a_connection.take("ferry"),
Answer::Watched("ferry".to_string())
);
assert_eq!(
held_by_a_connection.take("arkham"),
Answer::Unwatched("arkham".to_string())
);
}
#[test]
fn a_message_that_names_no_project_is_malformed() {
let reported = watching(["arkham", "ferry"]);
for message in ["", "\n", " ", "\t \r\n"] {
assert_eq!(reported.take(message), Answer::Malformed, "for {message:?}");
}
}
#[test]
fn a_message_too_long_to_be_a_project_name_is_malformed() {
let reported = watching(["arkham", "ferry"]);
let shout = "a".repeat(LONGEST_MESSAGE + 1);
assert_eq!(reported.take(&shout), Answer::Malformed);
}
#[test]
fn without_a_runtime_directory_there_is_no_inbound_channel() {
let (changed, _changes) = mpsc::channel();
let refused = listen(None, &watching(["arkham", "ferry"]), changed);
assert!(matches!(refused, Err(Refused::NoRuntimeDirectory)));
}
#[test]
fn a_writer_naming_a_watched_project_wakes_the_loop() {
let at = a_socket_path("wakes-the-loop");
let reported = watching(["arkham"]);
let (_socket, changes) = open(&at, &reported);
say(&at, &[("arkham\n", "ok arkham")]);
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some("arkham".to_string()),
"the loop was told which project to collect"
);
}
#[test]
fn a_writer_naming_something_bdi_does_not_watch_is_told_so_and_the_loop_sleeps_on() {
let at = a_socket_path("names-a-stranger");
let (_socket, changes) = open(&at, &watching(["arkham", "ferry"]));
say(&at, &[("ghost\n", "unknown ghost")]);
assert!(
changes.recv_timeout(Duration::from_millis(100)).is_err(),
"nothing bdi watches changed, so there is nothing to collect"
);
}
#[test]
fn a_malformed_message_is_dropped_without_disturbing_the_ones_beside_it() {
let at = a_socket_path("malformed");
let (_socket, changes) = open(&at, &watching(["arkham", "ferry"]));
say(&at, &[("\n", "malformed"), ("arkham\n", "ok arkham")]);
assert!(changes.recv_timeout(A_MOMENT).is_ok());
}
#[test]
fn a_line_that_never_ends_is_malformed_and_the_writer_is_let_go() {
let at = a_socket_path("never-ends");
let reported = watching(["arkham"]);
let (_socket, changes) = open(&at, &reported);
let (mut writing, mut reading) = connect(&at);
let unending = format!("arkham{}", " ".repeat(LONGEST_MESSAGE * 2));
write!(writing, "{unending}").expect("bdi takes the message");
writing
.shutdown(Shutdown::Write)
.expect("the writer has said all it is going to");
let mut said = String::new();
reading.read_line(&mut said).expect("bdi answers");
assert_eq!(
said.trim_end(),
"malformed",
"the prefix bdi read is not the name it spells"
);
let mut afterwards = String::new();
reading
.read_to_string(&mut afterwards)
.expect("bdi is done");
assert_eq!(
afterwards, "",
"bdi let the writer go rather than answering the rest of its line"
);
assert!(
changes.recv_timeout(Duration::from_millis(100)).is_err(),
"nothing bdi watches was named, so there is nothing to collect"
);
}
#[test]
fn the_longest_message_that_still_ends_is_taken() {
let at = a_socket_path("longest-that-ends");
let brink = "a".repeat(LONGEST_MESSAGE - 1);
let reported = watching([brink.as_str(), "arkham"]);
let (_socket, changes) = open(&at, &reported);
say(
&at,
&[
(&format!("{brink}\n"), &format!("ok {brink}")),
("arkham\n", "ok arkham"),
],
);
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some(brink),
"the loop was told to collect for the name on the boundary"
);
}
#[test]
fn a_writer_may_stay_and_speak_more_than_once() {
let at = a_socket_path("stays-and-speaks");
let (_socket, changes) = open(&at, &watching(["arkham", "ferry"]));
say(&at, &[("arkham\n", "ok arkham"), ("ferry\n", "ok ferry")]);
for said in ["arkham", "ferry"] {
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some(said.to_string()),
"the channel did not carry {said} on"
);
}
}
#[test]
fn writers_that_know_nothing_of_each_other_are_all_heard() {
let at = a_socket_path("several-writers");
let (_socket, changes) = open(&at, &watching(["arkham", "ferry"]));
say(&at, &[("arkham\n", "ok arkham")]);
say(&at, &[("ferry\n", "ok ferry")]);
for said in ["arkham", "ferry"] {
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some(said.to_string()),
"the channel did not carry {said} on"
);
}
}
#[test]
fn a_stale_socket_from_a_crashed_run_is_reclaimed() {
let at = a_socket_path("stale-socket");
std::fs::create_dir_all(at.parent().expect("the socket is in a directory"))
.expect("a directory to put the socket in");
drop(UnixListener::bind(&at).expect("a socket the crashed run left"));
assert!(at.exists(), "the crashed run's socket is still there");
let reported = watching(["arkham", "ferry"]);
let (_socket, changes) = open(&at, &reported);
say(&at, &[("arkham\n", "ok arkham")]);
assert!(changes.recv_timeout(A_MOMENT).is_ok());
}
#[test]
fn the_directory_bdi_makes_for_its_socket_is_this_users_own() {
let at = a_socket_path("directory-mode");
let (_socket, _changes) = open(&at, &watching(["arkham"]));
let directory = at.parent().expect("the socket is in a directory");
let mode = std::fs::metadata(directory)
.expect("the directory bdi made")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, ONLY_THIS_USER_MAY_ENTER,
"nobody else may enter the directory bdi made to put its socket in"
);
}
#[test]
fn a_directory_others_may_take_a_name_in_is_one_bdi_will_not_bind_in() {
let at = a_socket_in_a_directory_moded("open-directory", 0o777);
let (changed, _changes) = mpsc::channel();
let refused = listen(Some(at.clone()), &watching(["arkham"]), changed);
let named = match refused.err() {
Some(Refused::NameOthersMayTake(directory)) => directory,
otherwise => panic!("a directory anyone may write in is refused, not {otherwise:?}"),
};
assert_eq!(
std::fs::canonicalize(&named).ok(),
at.parent()
.and_then(|directory| std::fs::canonicalize(directory).ok()),
"and it is that directory the refusal names"
);
assert!(!at.exists(), "and nothing of bdi's is left at the name");
}
#[test]
fn a_way_down_that_cannot_be_read_is_refused_rather_than_passed() {
let around = std::env::temp_dir().join(format!("bdi-loop-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&around);
std::fs::create_dir_all(&around).expect("a directory to keep the test's own in");
let itself = around.join("itself");
std::os::unix::fs::symlink("itself", &itself).expect("a link pointed at its own name");
match only_this_user_may_take_a_name_under(&itself) {
Err(Refused::Unopenable(named, _)) => assert_eq!(
named, itself,
"and the refusal names the way down it could not read"
),
otherwise => panic!("a way down that cannot be read is refused, not {otherwise:?}"),
}
}
#[test]
fn a_private_directory_under_one_others_may_take_a_name_in_is_refused_for_that_one() {
let shared = a_socket_in_a_directory_moded("shared-above", 0o777)
.parent()
.expect("the socket is in a directory")
.to_path_buf();
let at = shared.join("mine").join("changes.sock");
let (changed, _changes) = mpsc::channel();
let refused = listen(Some(at.clone()), &watching(["arkham"]), changed);
let named = match refused.err() {
Some(Refused::NameOthersMayTake(directory)) => directory,
otherwise => panic!("a path under a shared directory is refused, not {otherwise:?}"),
};
assert_eq!(
std::fs::canonicalize(&named).ok(),
std::fs::canonicalize(&shared).ok(),
"and the refusal names the directory at fault rather than the socket's own"
);
}
#[test]
fn a_link_out_of_a_shared_directory_is_refused_for_the_directory_holding_it() {
let shared = a_socket_in_a_directory_moded("shared-holding-a-link", 0o777)
.parent()
.expect("the socket is in a directory")
.to_path_buf();
let mine = shared
.parent()
.expect("the test's own directory is around it")
.join("mine");
std::fs::create_dir_all(&mine).expect("somewhere of this user's own to point at");
std::os::unix::fs::symlink(&mine, shared.join("link")).expect("a link anybody may repoint");
let at = shared.join("link").join("changes.sock");
let (changed, _changes) = mpsc::channel();
let refused = listen(Some(at), &watching(["arkham"]), changed);
let named = match refused.err() {
Some(Refused::NameOthersMayTake(directory)) => directory,
otherwise => panic!("a path through a shared directory is refused, not {otherwise:?}"),
};
assert_eq!(
std::fs::canonicalize(&named).ok(),
std::fs::canonicalize(&shared).ok(),
"and it is the directory holding the link that is named, which resolving loses"
);
}
#[test]
fn a_socket_named_without_a_directory_is_judged_where_the_run_was_started() {
assert_eq!(directory_holding(Path::new("changes.sock")), Path::new("."));
assert_eq!(
directory_holding(Path::new("beady-eye/changes.sock")),
Path::new("beady-eye")
);
assert_eq!(
directory_holding(Path::new("/tmp/changes.sock")),
Path::new("/tmp")
);
}
#[test]
fn the_root_is_judged_where_the_name_is_in_it_and_left_alone_above() {
assert_eq!(
directories_on(Path::new("/")).collect::<Vec<_>>(),
[Path::new("/")],
"a socket named in the root is judged by the root"
);
assert_eq!(
directories_on(Path::new("/tmp/beady-eye")).collect::<Vec<_>>(),
[Path::new("/tmp/beady-eye"), Path::new("/tmp")],
"and above the socket's own directory the root is left out"
);
}
#[test]
fn a_directory_is_this_users_to_bind_under_by_its_owner_as_well_as_its_mode() {
let me = 501;
let them = 1000;
for (how, owner, taken, what) in [
(0o700, me, false, "a directory of this user's own"),
(0o755, THE_SYSTEM, false, "one of the system's"),
(0o755, them, true, "a narrow one somebody else owns"),
(0o700, them, true, "even a private one somebody else owns"),
(
0o777,
me,
true,
"one of this user's anybody may take a name in",
),
(0o770, me, true, "one of this user's their group may"),
(0o760, me, false, "one their group may write and not search"),
(0o1777, me, false, "a sticky one of this user's"),
(
0o1777,
THE_SYSTEM,
false,
"a sticky one of the system's, which /tmp is",
),
(0o1777, them, true, "a sticky one somebody else owns"),
] {
assert_eq!(
others_may_take_a_name_in(how, owner, me),
taken,
"{what} ({how:04o}, owner {owner})"
);
}
}
#[test]
fn a_file_that_replaced_the_socket_under_a_run_outlives_it() {
let at = a_socket_path("replaced-socket");
let (socket, _changes) = open(&at, &watching(["arkham"]));
std::fs::remove_file(&at).expect("somebody else takes the name");
std::fs::write(&at, "what they put there").expect("and leaves their own file at it");
drop(socket);
assert_eq!(
std::fs::read_to_string(&at).ok().as_deref(),
Some("what they put there"),
"the name no longer holds the socket this run bound, so it is not this run's to clear"
);
}
#[test]
fn a_path_holding_something_that_is_not_a_socket_is_left_where_it_is() {
let at = a_socket_path("not-a-socket");
std::fs::create_dir_all(at.parent().expect("the socket is in a directory"))
.expect("a directory to put the socket in");
std::fs::write(&at, "what the reader meant to keep").expect("a file to be typed over");
let (changed, _changes) = mpsc::channel();
let refused = listen(Some(at.clone()), &watching(["arkham"]), changed);
assert!(
matches!(refused, Err(Refused::NotASocket(_))),
"a path holding something else is refused for what is there"
);
assert_eq!(
std::fs::read_to_string(&at).ok().as_deref(),
Some("what the reader meant to keep"),
"the file is still the reader's"
);
}
#[test]
fn a_socket_another_bdi_is_listening_on_is_left_alone() {
let at = a_socket_path("two-bdis");
let reported = watching(["arkham", "ferry"]);
let (_first, _changes) = open(&at, &reported);
let (changed, _changes) = mpsc::channel();
let second = listen(Some(at.clone()), &reported, changed);
assert!(matches!(second, Err(Refused::AlreadyListening(_))));
say(&at, &[("arkham\n", "ok arkham")]);
}
#[test]
fn the_line_left_on_the_primary_screen_says_how_to_find_who_is_holding_it() {
let said = Refused::AlreadyListening(PathBuf::from("/run/user/1000/x.sock")).to_string();
assert!(said.contains("another bdi"), "{said}");
assert!(said.contains("/run/user/1000/x.sock"), "{said}");
assert!(said.contains("ss -lxp or lsof -U names which"), "{said}");
}
#[test]
fn the_remedy_for_a_held_socket_says_to_restart_bdi() {
let said = Refused::AlreadyListening(PathBuf::from("/run/user/1000/x.sock")).to_string();
assert!(said.contains("restart bdi"), "{said}");
}
#[test]
fn the_line_for_a_session_that_owns_no_directory_says_how_to_name_a_path() {
let said = Refused::NoRuntimeDirectory.to_string();
assert!(said.contains(RUNTIME_DIRECTORY), "{said}");
assert!(said.contains("--socket"), "{said}");
assert!(said.contains("socket under [changes]"), "{said}");
}
#[test]
fn the_socket_goes_with_the_run_that_made_it() {
let at = a_socket_path("removed-on-exit");
let (socket, _changes) = open(&at, &watching(["arkham", "ferry"]));
assert!(at.exists());
drop(socket);
assert!(!at.exists(), "the next run has nothing to reclaim");
}
#[test]
fn writers_find_bdi_under_the_directory_the_session_owns() {
let socket = under(Some(Path::new("/run/user/1000")));
assert_eq!(
socket,
Some(PathBuf::from("/run/user/1000/beady-eye/changes.sock"))
);
assert_eq!(under(None), None);
}
#[test]
fn a_run_told_where_to_listen_listens_there() {
let told = PathBuf::from("/var/folders/T/bdi/changes.sock");
assert_eq!(where_writers_find_bdi(Some(told.clone())), Some(told));
}
}