use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::session_files::SessionFile;
use super::running::{Running, SecurityView};
use super::spec::Key;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreError {
SecuredUnreadable(String),
PersistFailed(String),
}
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StoreError::SecuredUnreadable(message) | StoreError::PersistFailed(message) => {
f.write_str(message)
}
}
}
}
impl std::error::Error for StoreError {}
pub trait Store: Send + Sync {
fn load(&self) -> Result<Option<SecurityView>, StoreError>;
fn save(&self, view: &SecurityView) -> Result<(), StoreError>;
fn clear(&self) -> Result<(), StoreError>;
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Document {
#[serde(default, skip_serializing_if = "Option::is_none")]
allowed_domains: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
action_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
confirm_actions: Option<String>,
}
impl From<&SecurityView> for Document {
fn from(view: &SecurityView) -> Document {
Document {
allowed_domains: view.allowed_domains.clone(),
action_policy: view.action_policy_path.clone(),
confirm_actions: view.confirm_actions.clone(),
}
}
}
impl From<Document> for SecurityView {
fn from(doc: Document) -> SecurityView {
SecurityView {
allowed_domains: doc.allowed_domains,
action_policy_path: doc.action_policy,
confirm_actions: doc.confirm_actions,
}
}
}
#[cfg(unix)]
fn untrusted_reason(uid: u32, euid: u32, mode: u32) -> Option<String> {
if uid != euid {
return Some("it belongs to another user".to_string());
}
(mode & 0o077 != 0).then(|| {
format!(
"it is accessible by other users (mode {:04o})",
mode & 0o7777
)
})
}
pub struct FsStore {
path: PathBuf,
}
impl FsStore {
pub fn new(dir: &Path, session: &str) -> FsStore {
FsStore {
path: SessionFile::Settings.path_in(dir, session),
}
}
fn read_trusted(&self) -> Result<Option<String>, StoreError> {
let meta = match fs::symlink_metadata(&self.path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(self.unreadable(&e.to_string())),
};
if meta.file_type().is_symlink() {
return Err(self.unreadable("it is a symbolic link"));
}
if !meta.is_file() {
return Err(self.unreadable("it is not a regular file"));
}
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut file = match options.open(&self.path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(self.unreadable(&e.to_string())),
};
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let opened = file
.metadata()
.map_err(|e| self.unreadable(&e.to_string()))?;
let euid = unsafe { libc::geteuid() };
if let Some(why) = untrusted_reason(opened.uid(), euid, opened.mode()) {
return Err(self.unreadable(&why));
}
}
let mut raw = String::new();
std::io::Read::read_to_string(&mut file, &mut raw)
.map_err(|e| self.unreadable(&e.to_string()))?;
Ok(Some(raw))
}
fn unreadable(&self, why: &str) -> StoreError {
StoreError::SecuredUnreadable(format!(
"session settings file {} cannot be used: {}",
self.path.display(),
why
))
}
fn persist_failed(&self, what: &str, path: &Path, e: &dyn fmt::Display) -> StoreError {
StoreError::PersistFailed(format!("cannot {} {}: {}", what, path.display(), e))
}
}
impl Store for FsStore {
fn load(&self) -> Result<Option<SecurityView>, StoreError> {
let Some(raw) = self.read_trusted()? else {
return Ok(None);
};
serde_json::from_str::<Document>(&raw)
.map(|doc| Some(doc.into()))
.map_err(|e| self.unreadable(&format!("it is not valid ({})", e)))
}
fn save(&self, view: &SecurityView) -> Result<(), StoreError> {
let raw = serde_json::to_string(&Document::from(view)).map_err(|e| {
StoreError::PersistFailed(format!("cannot serialize session settings: {}", e))
})?;
if let Some(dir) = self.path.parent() {
fs::create_dir_all(dir)
.map_err(|e| self.persist_failed("create socket dir", dir, &e))?;
}
let tmp = self
.path
.with_extension(format!("settings.tmp.{}", std::process::id()));
let written = (|| -> Result<(), StoreError> {
let open = || {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
options.open(&tmp)
};
let mut file = match open() {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
fs::remove_file(&tmp).map_err(|e| self.persist_failed("remove", &tmp, &e))?;
open()
}
other => other,
}
.map_err(|e| self.persist_failed("create", &tmp, &e))?;
file.write_all(raw.as_bytes())
.map_err(|e| self.persist_failed("write", &tmp, &e))?;
file.sync_all()
.map_err(|e| self.persist_failed("sync", &tmp, &e))?;
drop(file);
fs::rename(&tmp, &self.path)
.map_err(|e| self.persist_failed("replace", &self.path, &e))?;
#[cfg(unix)]
if let Some(dir) = self.path.parent() {
fs::File::open(dir)
.and_then(|d| d.sync_all())
.map_err(|e| self.persist_failed("sync", dir, &e))?;
}
Ok(())
})();
if written.is_err() {
let _ = fs::remove_file(&tmp);
}
written
}
fn clear(&self) -> Result<(), StoreError> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(self.persist_failed("remove", &self.path, &e)),
}
}
}
pub fn clear_file(session: &str) -> Result<(), String> {
clear_path(&SessionFile::Settings.path(session))
}
fn clear_path(path: &Path) -> Result<(), String> {
let err = match fs::remove_file(path) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => e,
};
let is_dir_like = fs::symlink_metadata(path)
.map(|m| m.is_dir() || m.file_type().is_symlink())
.unwrap_or(false);
if is_dir_like && fs::remove_dir(path).is_ok() {
return Ok(());
}
Err(format!(
"session settings {} could not be removed ({err}); remove it by hand",
path.display()
))
}
const REFUSAL_PREFIX: &str = "Stored session settings cannot be used";
pub fn is_refusal(error: &str) -> bool {
error.contains(REFUSAL_PREFIX)
}
fn shell_arg(arg: &str) -> String {
let plain = !arg.is_empty()
&& arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_-./:=@%+,".contains(c));
if plain {
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
}
pub fn orphan_sessions(dir: &Path, is_live: impl Fn(&str) -> bool) -> Vec<String> {
let extension = format!(".{}", SessionFile::Settings.extension());
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut sessions: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
let session = name.strip_suffix(&extension)?.to_string();
(!session.is_empty() && !is_live(&session)).then_some(session)
})
.collect();
sessions.sort();
sessions
}
#[cfg(test)]
#[derive(Default)]
pub struct MemStore {
value: std::sync::Mutex<Option<SecurityView>>,
}
#[cfg(test)]
impl MemStore {
pub fn stored(&self) -> Option<SecurityView> {
self.value.lock().unwrap().clone()
}
}
#[cfg(test)]
impl Store for MemStore {
fn load(&self) -> Result<Option<SecurityView>, StoreError> {
Ok(self.stored())
}
fn save(&self, view: &SecurityView) -> Result<(), StoreError> {
*self.value.lock().unwrap() = Some(view.clone());
Ok(())
}
fn clear(&self) -> Result<(), StoreError> {
*self.value.lock().unwrap() = None;
Ok(())
}
}
impl Running {
pub fn boot(store: &dyn Store, session: &str) -> Result<Running, StoreError> {
let env = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
let namespace = env("AGENT_BROWSER_NAMESPACE");
let socket_dir = env("AGENT_BROWSER_SOCKET_DIR");
Running::boot_from(
Running::from_env(),
store,
session,
namespace.as_deref(),
socket_dir.as_deref(),
)
}
pub(super) fn boot_from(
mut seed: Running,
store: &dyn Store,
session: &str,
namespace: Option<&str>,
socket_dir: Option<&str>,
) -> Result<Running, StoreError> {
match store.load() {
Ok(Some(view)) => seed.set_security(&view),
Ok(None) => {
let view = seed.security();
if view != SecurityView::default() {
store.save(&view)?;
}
}
Err(StoreError::SecuredUnreadable(why)) => {
let socket_dir = socket_dir
.map(|dir| format!("AGENT_BROWSER_SOCKET_DIR={} ", shell_arg(dir)))
.unwrap_or_default();
let namespace = namespace
.map(|ns| format!(" --namespace {}", shell_arg(ns)))
.unwrap_or_default();
let mut end = why.len().min(300);
while !why.is_char_boundary(end) {
end -= 1;
}
return Err(StoreError::SecuredUnreadable(format!(
"{}; to clear them run: {}agent-browser{} --session {} close. {}",
REFUSAL_PREFIX,
socket_dir,
namespace,
shell_arg(session),
&why[..end]
)));
}
Err(e) => return Err(e),
}
Ok(seed)
}
pub fn set_security(&mut self, view: &SecurityView) {
self.put_back(
Key::AllowedDomains,
view.allowed_domains.clone().map(Value::from),
);
self.put_back(
Key::ActionPolicy,
view.action_policy_path.clone().map(Value::from),
);
self.put_back(
Key::ConfirmActions,
view.confirm_actions.clone().map(Value::from),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
fn full_view() -> SecurityView {
SecurityView {
allowed_domains: Some(vec!["example.com".into(), "*.example.org".into()]),
action_policy_path: Some("relative/policy.json".into()),
confirm_actions: Some("navigate,click".into()),
}
}
fn lookup(pairs: &'static [(&'static str, &'static str)]) -> Running {
Running::from_lookup(|name| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
})
}
fn write_private(path: &Path, contents: &str) {
fs::write(path, contents).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
}
fn entries(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn test_fs_store_round_trips_every_setting() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path(), "s");
assert_eq!(store.load(), Ok(None));
store.save(&full_view()).unwrap();
assert_eq!(store.load(), Ok(Some(full_view())));
}
#[test]
fn test_fs_store_keeps_an_empty_list_apart_from_no_list() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path(), "s");
let cleared = SecurityView {
allowed_domains: Some(Vec::new()),
..SecurityView::default()
};
store.save(&cleared).unwrap();
assert_eq!(store.load(), Ok(Some(cleared)));
}
#[test]
fn test_fs_store_replaces_the_previous_value() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path(), "s");
store.save(&full_view()).unwrap();
let next = SecurityView {
confirm_actions: Some("eval".into()),
..SecurityView::default()
};
store.save(&next).unwrap();
assert_eq!(store.load(), Ok(Some(next)));
}
#[cfg(unix)]
#[test]
fn test_fs_store_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path(), "s");
store.save(&full_view()).unwrap();
store.save(&full_view()).unwrap();
let mode = fs::metadata(dir.path().join("s.settings"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn test_fs_store_leaves_no_temp_file_after_a_save() {
let dir = tempfile::tempdir().unwrap();
FsStore::new(dir.path(), "s").save(&full_view()).unwrap();
assert_eq!(entries(dir.path()), ["s.settings"]);
}
#[test]
fn test_fs_store_removes_the_temp_file_when_the_save_fails() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("s.settings")).unwrap();
let store = FsStore::new(dir.path(), "s");
let err = store.save(&full_view()).unwrap_err();
assert!(matches!(err, StoreError::PersistFailed(_)), "{err}");
assert_eq!(entries(dir.path()), ["s.settings"]);
}
#[test]
fn test_fs_store_clear_removes_the_file_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path(), "s");
store.save(&full_view()).unwrap();
store.clear().unwrap();
store.clear().unwrap();
assert_eq!(store.load(), Ok(None));
assert!(entries(dir.path()).is_empty());
}
#[test]
fn test_fs_store_clear_removes_a_corrupt_file() {
let dir = tempfile::tempdir().unwrap();
write_private(&dir.path().join("s.settings"), "{not json");
let store = FsStore::new(dir.path(), "s");
assert!(store.load().is_err());
store.clear().unwrap();
assert_eq!(store.load(), Ok(None));
}
#[test]
fn test_fs_store_sessions_do_not_share_a_file() {
let dir = tempfile::tempdir().unwrap();
FsStore::new(dir.path(), "a").save(&full_view()).unwrap();
assert_eq!(FsStore::new(dir.path(), "b").load(), Ok(None));
}
#[test]
fn test_fs_store_directories_are_namespaces() {
let one = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
FsStore::new(one.path(), "s").save(&full_view()).unwrap();
assert_eq!(FsStore::new(other.path(), "s").load(), Ok(None));
}
#[test]
fn test_boot_refuses_a_corrupt_file_and_names_the_way_out() {
let dir = tempfile::tempdir().unwrap();
write_private(&dir.path().join("work.settings"), "{not json");
let store = FsStore::new(dir.path(), "work");
let err = Running::boot_from(Running::defaults(), &store, "work", None, None).unwrap_err();
let StoreError::SecuredUnreadable(message) = err else {
panic!("expected SecuredUnreadable, got {err:?}");
};
assert!(message.contains("work.settings"), "{message}");
assert!(
message.contains("agent-browser --session work close"),
"{message}"
);
}
#[test]
fn test_boot_refuses_a_file_with_the_wrong_shape() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("s.settings"),
r#"{"allowedDomains":"a.com"}"#,
)
.unwrap();
let store = FsStore::new(dir.path(), "s");
assert!(Running::boot_from(Running::defaults(), &store, "s", None, None).is_err());
}
#[cfg(unix)]
#[test]
fn test_boot_refuses_a_file_other_users_can_read() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path(), "s");
store.save(&full_view()).unwrap();
let path = dir.path().join("s.settings");
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
let err = Running::boot_from(Running::defaults(), &store, "s", None, None).unwrap_err();
let StoreError::SecuredUnreadable(message) = err else {
panic!("expected SecuredUnreadable, got {err:?}");
};
assert!(message.contains("0644"), "{message}");
assert!(message.contains("s.settings"), "{message}");
}
#[test]
fn test_boot_refusal_puts_the_way_out_first_and_fits_the_stderr_cap() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("d".repeat(150)).join("e".repeat(150));
fs::create_dir_all(&dir).unwrap();
write_private(&dir.join("work.settings"), "{not json");
let store = FsStore::new(&dir, "work");
let err = Running::boot_from(Running::defaults(), &store, "work", None, None).unwrap_err();
let message = err.to_string();
assert!(message.len() < 500, "{} bytes: {message}", message.len());
let hint = message.find("agent-browser --session work close").unwrap();
assert!(hint < message.find("work.settings").unwrap_or(usize::MAX));
assert!(
message.contains("dddd"),
"the file is still named: {message}"
);
}
#[test]
fn test_boot_refusal_names_the_namespace_when_one_is_in_effect() {
let dir = tempfile::tempdir().unwrap();
write_private(&dir.path().join("s.settings"), "{not json");
let store = FsStore::new(dir.path(), "s");
let err =
Running::boot_from(Running::defaults(), &store, "s", Some("team a"), None).unwrap_err();
assert!(
err.to_string()
.contains("agent-browser --namespace 'team a' --session s close"),
"{err}"
);
}
#[test]
fn test_boot_refusal_names_the_socket_dir_when_one_is_set() {
let dir = tempfile::tempdir().unwrap();
write_private(&dir.path().join("s.settings"), "{not json");
let store = FsStore::new(dir.path(), "s");
let err = Running::boot_from(
Running::defaults(),
&store,
"s",
Some("team"),
Some("/tmp/ab sock"),
)
.unwrap_err();
assert!(
err.to_string().contains(
"AGENT_BROWSER_SOCKET_DIR='/tmp/ab sock' agent-browser --namespace team --session s close"
),
"{err}"
);
}
#[cfg(unix)]
#[test]
fn test_load_refuses_a_symlink_even_to_a_good_file() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
write_private(&real, r#"{"allowedDomains":["a.com"]}"#);
std::os::unix::fs::symlink(&real, dir.path().join("s.settings")).unwrap();
let err = FsStore::new(dir.path(), "s").load().unwrap_err();
assert!(err.to_string().contains("symbolic link"), "{err}");
}
#[cfg(unix)]
#[test]
fn test_untrusted_reason_checks_owner_and_mode() {
assert_eq!(untrusted_reason(501, 501, 0o100600), None);
assert!(untrusted_reason(0, 501, 0o100600)
.unwrap()
.contains("another user"));
assert!(untrusted_reason(501, 501, 0o100640)
.unwrap()
.contains("0640"));
}
#[cfg(unix)]
#[test]
fn test_save_never_writes_through_a_planted_temp_symlink() {
let dir = tempfile::tempdir().unwrap();
let victim = dir.path().join("victim");
fs::write(&victim, "precious").unwrap();
let tmp = dir
.path()
.join(format!("s.settings.tmp.{}", std::process::id()));
std::os::unix::fs::symlink(&victim, &tmp).unwrap();
let store = FsStore::new(dir.path(), "s");
store.save(&full_view()).unwrap();
assert_eq!(fs::read_to_string(&victim).unwrap(), "precious");
assert_eq!(store.load(), Ok(Some(full_view())));
assert!(!tmp.exists() && tmp.symlink_metadata().is_err());
}
#[test]
fn test_save_replaces_a_stale_temp_file() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir
.path()
.join(format!("s.settings.tmp.{}", std::process::id()));
fs::write(&tmp, "stale").unwrap();
let store = FsStore::new(dir.path(), "s");
store.save(&full_view()).unwrap();
assert_eq!(store.load(), Ok(Some(full_view())));
assert_eq!(entries(dir.path()), ["s.settings"]);
}
#[test]
fn test_shell_arg_quotes_only_when_needed() {
assert_eq!(shell_arg("work-1.a"), "work-1.a");
assert_eq!(shell_arg("team a"), "'team a'");
assert_eq!(shell_arg("it's"), "'it'\\''s'");
assert_eq!(shell_arg(""), "''");
assert_eq!(shell_arg("a;rm"), "'a;rm'");
}
#[test]
fn test_boot_without_a_file_makes_either_policy_variable_absolute() {
let cwd = std::env::current_dir().unwrap();
let expected = cwd.join("rel/policy.json");
for pairs in [
&[("AGENT_BROWSER_ACTION_POLICY", "rel/policy.json")][..],
&[("AGENT_BROWSER_POLICY", "rel/policy.json")][..],
] {
let seed = Running::from_lookup(|name| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
});
assert_eq!(
seed.security().action_policy_path.as_deref(),
expected.to_str(),
"{pairs:?}"
);
}
}
#[test]
fn test_boot_stored_settings_win_over_the_environment() {
let store = MemStore::default();
store.save(&full_view()).unwrap();
let seed = lookup(&[
("AGENT_BROWSER_ALLOWED_DOMAINS", "other.com"),
("AGENT_BROWSER_ACTION_POLICY", "/env/policy.json"),
("AGENT_BROWSER_CONFIRM_ACTIONS", "eval"),
]);
let running = Running::boot_from(seed, &store, "s", None, None).unwrap();
assert_eq!(running.security(), full_view());
}
#[test]
fn test_boot_stored_file_is_complete_for_the_three_settings() {
let store = MemStore::default();
store
.save(&SecurityView {
allowed_domains: Some(vec!["a.com".into()]),
..SecurityView::default()
})
.unwrap();
let seed = lookup(&[("AGENT_BROWSER_CONFIRM_ACTIONS", "eval")]);
let running = Running::boot_from(seed, &store, "s", None, None).unwrap();
assert_eq!(running.security().confirm_actions, None);
assert_eq!(
running.security().allowed_domains,
Some(vec!["a.com".to_string()])
);
}
#[test]
fn test_boot_without_a_file_uses_the_environment_and_stores_it() {
let store = MemStore::default();
let seed = lookup(&[("AGENT_BROWSER_ALLOWED_DOMAINS", "a.com, b.com")]);
let running = Running::boot_from(seed, &store, "s", None, None).unwrap();
let expected = SecurityView {
allowed_domains: Some(vec!["a.com".into(), "b.com".into()]),
..SecurityView::default()
};
assert_eq!(running.security(), expected);
assert_eq!(store.stored(), Some(expected));
}
#[test]
fn test_boot_without_a_file_or_security_settings_stores_nothing() {
let store = MemStore::default();
let seed = lookup(&[("AGENT_BROWSER_PROXY", "http://p:1")]);
Running::boot_from(seed, &store, "s", None, None).unwrap();
assert_eq!(store.stored(), None);
}
#[test]
fn test_orphan_sessions_lists_dead_sessions_only_and_removes_nothing() {
let dir = tempfile::tempdir().unwrap();
for name in [
"dead.settings",
"live.settings",
"dead.target",
"x.settings.tmp.1",
] {
fs::write(dir.path().join(name), "{}").unwrap();
}
let orphans = orphan_sessions(dir.path(), |s| s == "live");
assert_eq!(orphans, ["dead"]);
assert_eq!(entries(dir.path()).len(), 4);
}
#[test]
fn test_orphan_sessions_on_a_missing_directory_is_empty() {
assert!(orphan_sessions(Path::new("/nonexistent/ab-dir"), |_| false).is_empty());
}
#[test]
fn test_clear_file_removes_the_session_file_in_the_socket_dir() {
let guard = EnvGuard::new(&[
"AGENT_BROWSER_SOCKET_DIR",
"AGENT_BROWSER_NAMESPACE",
"XDG_RUNTIME_DIR",
]);
let dir = tempfile::tempdir().unwrap();
guard.set("AGENT_BROWSER_SOCKET_DIR", dir.path().to_str().unwrap());
guard.remove("AGENT_BROWSER_NAMESPACE");
let path = SessionFile::Settings.path("s");
fs::create_dir_all(path.parent().unwrap()).unwrap();
write_private(&path, "{not json");
let other = SessionFile::Settings.path("other");
fs::write(&other, "{}").unwrap();
clear_file("s").unwrap();
assert!(!path.exists());
assert!(other.exists());
}
#[test]
fn test_clear_path_removes_an_empty_directory_in_place_of_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.settings");
fs::create_dir(&path).unwrap();
clear_path(&path).unwrap();
assert!(!path.exists());
clear_path(&path).unwrap();
}
#[test]
fn test_clear_path_names_an_entry_it_cannot_remove() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.settings");
fs::create_dir(&path).unwrap();
fs::write(path.join("keep"), "x").unwrap();
let err = clear_path(&path).unwrap_err();
assert!(err.contains("s.settings"), "{err}");
assert!(path.join("keep").exists());
}
}