use std::path::PathBuf;
const PERMISSION_BITS_MASK: u32 = 0o777;
const FALLBACK_DIR_MODE: u32 = 0o700;
pub(crate) fn history_path() -> PathBuf {
fallback_user_dir().join("nwg-notifications-history.json")
}
pub(crate) fn config_path() -> PathBuf {
nwg_common::config::paths::config_dir("nwg-notifications").join("config.json")
}
const LEGACY_HISTORY_FILENAME: &str = "mac-notifications-history.json";
pub(crate) fn migrate_history_if_needed() -> Option<PathBuf> {
let new_path = history_path();
if new_path.exists() {
return None;
}
let candidates = legacy_history_candidates(&new_path);
migrate_history_from_candidates(&new_path, &candidates)
}
fn legacy_history_candidates(new_path: &std::path::Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
if let Some(parent) = new_path.parent() {
out.push(parent.join(LEGACY_HISTORY_FILENAME));
}
let uid = unsafe { libc::getuid() };
let legacy_tmp_path = PathBuf::from("/tmp")
.join(format!("mac-notifications-{uid}"))
.join(LEGACY_HISTORY_FILENAME);
if !out.contains(&legacy_tmp_path) {
out.push(legacy_tmp_path);
}
out
}
fn migrate_history_from_candidates(
new_path: &std::path::Path,
candidates: &[PathBuf],
) -> Option<PathBuf> {
for legacy_path in candidates {
if !legacy_path.exists() {
continue;
}
if let Err(e) = std::fs::copy(legacy_path, new_path) {
log::warn!(
"Failed to migrate legacy history file {} -> {}: {}",
legacy_path.display(),
new_path.display(),
e
);
continue;
}
log::info!(
"Migrated legacy history file {} -> {}",
legacy_path.display(),
new_path.display()
);
if let Err(e) = std::fs::remove_file(legacy_path) {
log::warn!(
"Migrated history file but failed to unlink legacy {}: {}",
legacy_path.display(),
e
);
}
return Some(new_path.to_path_buf());
}
None
}
pub(crate) fn status_path() -> PathBuf {
std::env::var("XDG_RUNTIME_DIR")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(fallback_user_dir)
.join("nwg-notifications-status.json")
}
fn fallback_user_dir() -> PathBuf {
fallback_user_dir_from(nwg_common::config::paths::cache_dir())
}
fn fallback_user_dir_from(cache_dir: Option<PathBuf>) -> PathBuf {
if let Some(dir) = cache_dir {
return dir;
}
let uid = unsafe { libc::getuid() };
let preferred = PathBuf::from("/tmp").join(format!("nwg-notifications-{uid}"));
if try_create_or_validate(&preferred, uid) {
return preferred;
}
let pid_suffixed =
PathBuf::from("/tmp").join(format!("nwg-notifications-{uid}-{}", std::process::id()));
if try_create_or_validate(&pid_suffixed, uid) {
log::warn!(
"Falling back to per-process dir {} because {} failed safety checks; \
persisted history will not survive daemon restarts in this configuration.",
pid_suffixed.display(),
preferred.display()
);
return pid_suffixed;
}
let mut last_attempted = pid_suffixed.clone();
for attempt in 0..RANDOMIZED_FALLBACK_ATTEMPTS {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let randomized =
PathBuf::from("/tmp").join(format!("nwg-notifications-{uid}-r{attempt}-{nanos:x}"));
last_attempted = randomized.clone();
if try_create_or_validate(&randomized, uid) {
log::warn!(
"Falling back to randomized dir {} after both per-UID ({}) and per-PID ({}) \
variants failed safety checks; persisted history will not survive daemon restarts.",
randomized.display(),
preferred.display(),
pid_suffixed.display()
);
return randomized;
}
}
log::error!(
"All {} randomized fallback dirs failed safety checks (last tried: {}). \
Subsequent file writes will fail; the daemon's /tmp environment is unsafe.",
RANDOMIZED_FALLBACK_ATTEMPTS,
last_attempted.display()
);
last_attempted
}
const RANDOMIZED_FALLBACK_ATTEMPTS: u32 = 16;
fn try_create_or_validate(dir: &std::path::Path, uid: libc::uid_t) -> bool {
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
match std::fs::DirBuilder::new()
.mode(FALLBACK_DIR_MODE)
.create(dir)
{
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
match std::fs::symlink_metadata(dir) {
Ok(meta) => {
let is_dir = meta.file_type().is_dir();
let owned = meta.uid() == uid;
let mode = meta.permissions().mode() & PERMISSION_BITS_MASK;
if is_dir && owned && mode == FALLBACK_DIR_MODE {
return true;
}
log::error!(
"Fallback dir {} fails safety checks (is_dir={is_dir}, \
owned_by_us={owned}, mode={mode:o} expected {:o}). Refusing to use.",
dir.display(),
FALLBACK_DIR_MODE
);
false
}
Err(e) => {
log::error!("Failed to stat fallback dir {}: {}", dir.display(), e);
false
}
}
}
Err(e) => {
log::error!("Failed to create fallback dir {}: {}", dir.display(), e);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvRestore {
snapshot: Vec<(String, Option<String>)>,
}
impl EnvRestore {
fn apply(overrides: &[(&str, Option<&str>)]) -> Self {
let mut snapshot: Vec<(String, Option<String>)> = Vec::new();
for (key, _) in overrides {
snapshot.push(((*key).to_string(), std::env::var(*key).ok()));
}
for (key, value) in overrides {
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
EnvRestore { snapshot }
}
}
impl Drop for EnvRestore {
fn drop(&mut self) {
for (key, original) in self.snapshot.drain(..) {
unsafe {
match original {
Some(v) => std::env::set_var(&key, v),
None => std::env::remove_var(&key),
}
}
}
}
}
fn with_env<R>(history_overrides: &[(&str, Option<&str>)], body: impl FnOnce() -> R) -> R {
let _guard = ENV_LOCK.lock().expect("env lock poisoned");
let _restore = EnvRestore::apply(history_overrides);
body()
}
#[test]
fn path_helpers_use_xdg_dirs_when_available_and_per_user_fallback_otherwise() {
let tmpdir = std::env::temp_dir().join(format!("nwg-paths-test-{}", std::process::id()));
std::fs::create_dir_all(&tmpdir).expect("setup tmpdir");
let runtime = tmpdir.join("runtime");
let cache = tmpdir.join("cache");
std::fs::create_dir_all(&runtime).expect("setup runtime");
std::fs::create_dir_all(&cache).expect("setup cache");
let actual = with_env(
&[("XDG_RUNTIME_DIR", Some(runtime.to_str().unwrap()))],
status_path,
);
assert_eq!(actual, runtime.join("nwg-notifications-status.json"));
let actual = with_env(
&[("XDG_CACHE_HOME", Some(cache.to_str().unwrap()))],
history_path,
);
assert_eq!(actual, cache.join("nwg-notifications-history.json"));
let actual = with_env(
&[
("XDG_RUNTIME_DIR", None),
("XDG_CACHE_HOME", Some(cache.to_str().unwrap())),
],
status_path,
);
assert_eq!(actual, cache.join("nwg-notifications-status.json"));
let uid = unsafe { libc::getuid() };
let actual = fallback_user_dir_from(None);
let expected = PathBuf::from("/tmp").join(format!("nwg-notifications-{uid}"));
assert_eq!(actual, expected);
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&actual)
.expect("fallback dir created")
.permissions()
.mode()
& PERMISSION_BITS_MASK;
assert_eq!(
mode, FALLBACK_DIR_MODE,
"fallback dir must be mode {FALLBACK_DIR_MODE:o} to bound cross-user reads, got {mode:o}"
);
let _ = std::fs::remove_dir_all(&tmpdir);
}
#[test]
fn try_create_or_validate_refuses_when_path_is_a_regular_file() {
let tmpdir =
std::env::temp_dir().join(format!("nwg-paths-safety-test-{}", std::process::id()));
std::fs::create_dir_all(&tmpdir).expect("setup tmpdir");
let trap = tmpdir.join("trap-as-file");
std::fs::write(&trap, b"i am a regular file, not a directory").expect("setup trap");
let uid = unsafe { libc::getuid() };
assert!(
!try_create_or_validate(&trap, uid),
"try_create_or_validate must refuse a path that exists as a non-directory"
);
let _ = std::fs::remove_dir_all(&tmpdir);
}
#[test]
fn migrate_history_if_needed_copies_and_unlinks_legacy_file() {
let tmpdir =
std::env::temp_dir().join(format!("nwg-paths-migration-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmpdir);
std::fs::create_dir_all(&tmpdir).expect("setup tmpdir");
let cache = tmpdir.join("cache");
std::fs::create_dir_all(&cache).expect("setup cache");
let legacy = cache.join("mac-notifications-history.json");
let new = cache.join("nwg-notifications-history.json");
let payload = b"[{\"id\":1,\"summary\":\"legacy\"}]";
std::fs::write(&legacy, payload).expect("seed legacy file");
let result = with_env(
&[("XDG_CACHE_HOME", Some(cache.to_str().unwrap()))],
migrate_history_if_needed,
);
assert_eq!(
result,
Some(new.clone()),
"migrate should report the new path on success"
);
assert!(
new.exists(),
"new history file should exist after migration"
);
assert!(
!legacy.exists(),
"legacy history file should be unlinked after migration"
);
let migrated = std::fs::read(&new).expect("read migrated file");
assert_eq!(
migrated.as_slice(),
payload,
"migrated file should have the same contents as the legacy one"
);
let result_again = with_env(
&[("XDG_CACHE_HOME", Some(cache.to_str().unwrap()))],
migrate_history_if_needed,
);
assert_eq!(
result_again, None,
"second call should be a no-op (new file already present)"
);
let _ = std::fs::remove_dir_all(&tmpdir);
}
#[test]
fn config_path_resolves_under_xdg_config_home_when_set() {
let tmpdir =
std::env::temp_dir().join(format!("nwg-paths-config-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmpdir);
std::fs::create_dir_all(&tmpdir).expect("setup tmpdir");
let actual = with_env(
&[("XDG_CONFIG_HOME", Some(tmpdir.to_str().unwrap()))],
config_path,
);
let expected = tmpdir.join("nwg-notifications").join("config.json");
assert_eq!(actual, expected);
let _ = std::fs::remove_dir_all(&tmpdir);
}
#[test]
fn legacy_history_candidates_includes_xdg_parent_and_tmp_per_uid_fallback() {
let uid = unsafe { libc::getuid() };
let xdg_parent = PathBuf::from("/some/xdg/dir");
let new_path = xdg_parent.join("nwg-notifications-history.json");
let candidates = legacy_history_candidates(&new_path);
assert!(
candidates.contains(&xdg_parent.join("mac-notifications-history.json")),
"candidates must include the same-parent legacy path; got {candidates:?}"
);
let expected_tmp_legacy = PathBuf::from("/tmp")
.join(format!("mac-notifications-{uid}"))
.join("mac-notifications-history.json");
assert!(
candidates.contains(&expected_tmp_legacy),
"candidates must include the legacy /tmp/mac-notifications-<uid>/ \
fallback path; got {candidates:?}"
);
assert_eq!(
candidates.first(),
Some(&xdg_parent.join("mac-notifications-history.json")),
"XDG-parent candidate must be tried first (most common); got {candidates:?}"
);
}
}