amphetamine 0.1.0

Reclaim memory and win scheduler contention on Apple Silicon, safely.
//! Non-negotiable safety invariants.
//!
//! Nothing in this module is configurable. Config can only ever *narrow* what
//! Amphetamine touches; it can never widen it past these checks. Every
//! destructive path in the crate routes through here first.

use std::path::{Component, Path, PathBuf};

/// Processes that must never be signalled, quit, or reprioritised — no matter
/// what a config file says. Matched case-insensitively against bundle ID,
/// localized name, and executable name.
///
/// Three groups: (1) the OS would break or the session would end, (2) the tools
/// the user is trying to accelerate *into*, (3) anything holding mutable state
/// that dies badly — VMs and container runtimes.
pub const NEVER_TOUCH: &[&str] = &[
    // (1) The system itself.
    "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",
    // (2) The work surface. Closing these defeats the entire point.
    "Cursor",
    "com.todesktop.230313mzl4w4u92",
    "Terminal",
    "com.apple.Terminal",
    "iTerm2",
    "com.googlecode.iterm2",
    "Ghostty",
    "com.mitchellh.ghostty",
    "WezTerm",
    "Alacritty",
    "kitty",
    "amph",
    "amphetamine",
    // (3) Virtual machines and container runtimes. An abrupt exit here can
    // corrupt a disk image, so these are reported but never acted on.
    "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",
];

/// PIDs at or below this are launchd and early system daemons.
const LOWEST_TOUCHABLE_PID: i32 = 100;

/// Why a target was refused. Carried into the report so a skip is always
/// explained rather than silently dropped.
#[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"),
        }
    }
}

/// The single gate every process-affecting action passes through.
///
/// `identities` is every name a process answers to (bundle ID, localized name,
/// executable name); a hit on any one of them refuses the whole target.
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));
    }
    // Signalling another user's process needs root, which we never have — but
    // refusing here keeps the failure legible instead of an EPERM surprise.
    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(())
}

/// Returns the denylist entry that matched, if any.
pub fn protected_match(identities: &[&str]) -> Option<&'static str> {
    NEVER_TOUCH
        .iter()
        .copied()
        .find(|&deny| identity_matches(identities, deny))
}

/// Whether any of a process's names answers to `pattern`, case-insensitively.
/// Shared by the built-in denylist and every user-supplied list so they cannot
/// disagree about what "Slack" means.
pub fn identity_matches(identities: &[&str], pattern: &str) -> bool {
    identities
        .iter()
        .any(|id| id.eq_ignore_ascii_case(pattern) || bundle_tail_matches(id, pattern))
}

/// Whether any pattern in `patterns` matches.
pub fn any_matches<S: AsRef<str>>(identities: &[&str], patterns: &[S]) -> bool {
    patterns
        .iter()
        .any(|p| identity_matches(identities, p.as_ref()))
}

/// Treats `com.foo.Bar` as also answering to `Bar`, so a denylist entry written
/// as a plain app name still catches a bundle ID and vice versa.
fn bundle_tail_matches(id: &str, deny: &str) -> bool {
    id.rsplit('.')
        .next()
        .is_some_and(|tail| tail.eq_ignore_ascii_case(deny) && id.contains('.'))
}

/// Refuses any filesystem path that is not strictly inside `root`.
///
/// Both sides are canonicalised first, which resolves symlinks — so a cache
/// entry that points outside the cache tree fails the check rather than
/// letting a delete escape. Equality with `root` is also refused: we clear a
/// cache directory's *contents*, never the directory itself.
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()))?;

    // Canonicalise the parent and rejoin the final component: canonicalising
    // `target` itself would resolve a symlink to its destination, and we want
    // to judge (and later delete) the link, not what it points at.
    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)
}

/// Cache buckets that hold live state despite living under `Caches`. Deleting
/// these causes re-syncs, re-downloads of iCloud content, or broken app
/// containers, so config cannot re-enable them. Matched against the top-level
/// directory name under a cache root.
pub const CACHE_NEVER: &[&str] = &[
    "CloudKit",
    "com.apple.containermanagerd",
    "com.apple.HomeKit",
    "com.apple.iCloudHelper",
    "com.apple.bird",
    "com.apple.Safari.SafeBrowsing",
    "FamilyCircle",
];

/// Caches belonging to real, installed applications that are nonetheless
/// skipped: developer tools whose caches cost real rebuild time, and browsers
/// whose caches hold your sessions.
///
/// This list only has to cover buckets that would otherwise pass the
/// installed-application test — bare tooling directories like `go-build` are
/// already excluded by that rule, so they do not belong here. Unlike
/// [`CACHE_NEVER`], config may opt back in.
pub const CACHE_SKIP_DEFAULT: &[&str] = &[
    // IDEs and developer applications.
    "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",
    // Browsers — clearing these signs you out of sites.
    "com.apple.Safari",
    "company.thebrowser.Browser",
    "com.google.Chrome",
    "Firefox",
    "Arc",
    "com.openai.atlas",
    "Comet",
];

/// Whether a cache bucket name is shaped like a bundle identifier.
///
/// The cheap pre-filter before the authoritative check of whether an
/// application with that identifier is actually installed.
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 == '_')
        })
}

/// Whether a cache bucket is permanently off limits.
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());
        // Bundle tail resolution: an unlisted bundle ID ending in a listed name.
        assert!(protected_match(&["com.example.Cursor"]).is_some());
        assert!(protected_match(&["com.example.Notes"]).is_none());
    }

    #[test]
    fn denylist_beats_any_single_identity() {
        // A process answering to several names is refused if *any* is denied.
        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());
        // The root itself is never a deletion target.
        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();

        // The link itself lives under root and may be removed.
        assert!(vet_path(&root, &link).is_ok());
        // Anything reached *through* it resolves outside and is refused.
        assert!(vet_path(&root, &link.join("victim")).is_err());

        std::fs::remove_dir_all(&root).ok();
        std::fs::remove_dir_all(&outside).ok();
    }
}