use std::path::{Component, Path, PathBuf};
pub const NEVER_TOUCH: &[&str] = &[
"kernel_task",
"launchd",
"WindowServer",
"loginwindow",
"logind",
"SystemUIServer",
"Finder",
"Dock",
"ControlCenter",
"NotificationCenter",
"Spotlight",
"coreaudiod",
"opendirectoryd",
"securityd",
"trustd",
"syslogd",
"distnoted",
"cfprefsd",
"UserEventAgent",
"powerd",
"bluetoothd",
"sharingd",
"hidd",
"backupd",
"fseventsd",
"mds",
"mds_stores",
"mdworker",
"com.apple.finder",
"com.apple.dock",
"com.apple.systemuiserver",
"com.apple.controlcenter",
"com.apple.notificationcenterui",
"com.apple.loginwindow",
"Cursor",
"com.todesktop.230313mzl4w4u92",
"Terminal",
"com.apple.Terminal",
"iTerm2",
"com.googlecode.iterm2",
"Ghostty",
"com.mitchellh.ghostty",
"WezTerm",
"Alacritty",
"kitty",
"amph",
"amphetamine",
"Docker",
"Docker Desktop",
"com.docker.docker",
"com.docker.backend",
"com.apple.Virtualization.VirtualMachine",
"VirtualBox",
"VMware Fusion",
"Parallels Desktop",
"prl_vm_app",
"UTM",
"qemu-system-aarch64",
"OrbStack",
"colima",
"limactl",
];
const LOWEST_TOUCHABLE_PID: i32 = 100;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Protected(&'static str),
SystemPid(i32),
NotOurs { uid: u32 },
SelfTarget,
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Protected(m) => write!(f, "protected ({m})"),
Self::SystemPid(p) => write!(f, "system pid {p}"),
Self::NotOurs { uid } => write!(f, "owned by uid {uid}, not you"),
Self::SelfTarget => write!(f, "that's Amphetamine itself"),
}
}
}
pub fn vet_process(pid: i32, uid: u32, identities: &[&str]) -> Result<(), Refusal> {
if pid == std::process::id() as i32 {
return Err(Refusal::SelfTarget);
}
if pid <= LOWEST_TOUCHABLE_PID {
return Err(Refusal::SystemPid(pid));
}
let me = unsafe { libc::getuid() };
if uid != me {
return Err(Refusal::NotOurs { uid });
}
if let Some(m) = protected_match(identities) {
return Err(Refusal::Protected(m));
}
Ok(())
}
pub fn protected_match(identities: &[&str]) -> Option<&'static str> {
NEVER_TOUCH
.iter()
.copied()
.find(|&deny| identity_matches(identities, deny))
}
pub fn identity_matches(identities: &[&str], pattern: &str) -> bool {
identities
.iter()
.any(|id| id.eq_ignore_ascii_case(pattern) || bundle_tail_matches(id, pattern))
}
pub fn any_matches<S: AsRef<str>>(identities: &[&str], patterns: &[S]) -> bool {
patterns
.iter()
.any(|p| identity_matches(identities, p.as_ref()))
}
fn bundle_tail_matches(id: &str, deny: &str) -> bool {
id.rsplit('.')
.next()
.is_some_and(|tail| tail.eq_ignore_ascii_case(deny) && id.contains('.'))
}
pub fn vet_path(root: &Path, target: &Path) -> anyhow::Result<PathBuf> {
let root = root
.canonicalize()
.map_err(|e| anyhow::anyhow!("cache root {} is unreadable: {e}", root.display()))?;
let (parent, name) = match (target.parent(), target.file_name()) {
(Some(p), Some(n)) => (p, n),
_ => anyhow::bail!("refusing path with no parent: {}", target.display()),
};
let resolved = parent
.canonicalize()
.map_err(|e| anyhow::anyhow!("cannot resolve {}: {e}", parent.display()))?
.join(name);
if !resolved.starts_with(&root) || resolved == root {
anyhow::bail!(
"refusing {} — escapes cache root {}",
resolved.display(),
root.display()
);
}
if resolved.components().any(|c| c == Component::ParentDir) {
anyhow::bail!("refusing path containing ..: {}", resolved.display());
}
Ok(resolved)
}
pub const CACHE_NEVER: &[&str] = &[
"CloudKit",
"com.apple.containermanagerd",
"com.apple.HomeKit",
"com.apple.iCloudHelper",
"com.apple.bird",
"com.apple.Safari.SafeBrowsing",
"FamilyCircle",
];
pub const CACHE_SKIP_DEFAULT: &[&str] = &[
"com.apple.dt.Xcode",
"com.apple.dt.xcodebuild",
"com.microsoft.VSCode",
"com.exafunction.windsurf",
"com.google.antigravity",
"com.unity3d.UnityEditor",
"com.unity3d.unityhub",
"JetBrains",
"Docker Desktop",
"dev.warp.Warp-Stable",
"com.parallels.desktop.console",
"Blender",
"com.apple.Safari",
"company.thebrowser.Browser",
"com.google.Chrome",
"Firefox",
"Arc",
"com.openai.atlas",
"Comet",
];
pub fn looks_like_bundle_id(name: &str) -> bool {
name.matches('.').count() >= 2
&& !name.contains('/')
&& name.split('.').all(|seg| {
!seg.is_empty()
&& seg
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
})
}
pub fn cache_is_never(bucket: &str) -> bool {
CACHE_NEVER.iter().any(|n| n.eq_ignore_ascii_case(bucket))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn denylist_catches_bundle_and_plain_name() {
assert!(protected_match(&["com.apple.finder"]).is_some());
assert!(protected_match(&["Finder"]).is_some());
assert!(protected_match(&["FINDER"]).is_some());
assert!(protected_match(&["com.example.Cursor"]).is_some());
assert!(protected_match(&["com.example.Notes"]).is_none());
}
#[test]
fn denylist_beats_any_single_identity() {
assert!(protected_match(&["Slack", "com.docker.docker"]).is_some());
}
#[test]
fn low_pids_and_foreign_uids_are_refused() {
let me = unsafe { libc::getuid() };
assert!(matches!(
vet_process(1, me, &["launchd"]),
Err(Refusal::SystemPid(1))
));
assert!(matches!(
vet_process(50_000, me + 1, &["Slack"]),
Err(Refusal::NotOurs { .. })
));
assert!(vet_process(50_000, me, &["Slack"]).is_ok());
}
#[test]
fn path_escapes_are_refused() {
let tmp = std::env::temp_dir().canonicalize().unwrap();
let root = tmp.join("amph-guard-test");
std::fs::create_dir_all(root.join("inner")).unwrap();
assert!(vet_path(&root, &root.join("inner")).is_ok());
assert!(vet_path(&root, &root).is_err());
assert!(vet_path(&root, &tmp.join("elsewhere")).is_err());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn symlinked_parent_cannot_escape_root() {
let tmp = std::env::temp_dir().canonicalize().unwrap();
let root = tmp.join("amph-guard-symlink");
let outside = tmp.join("amph-guard-outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let link = root.join("escape");
std::os::unix::fs::symlink(&outside, &link).ok();
assert!(vet_path(&root, &link).is_ok());
assert!(vet_path(&root, &link.join("victim")).is_err());
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&outside).ok();
}
}