maclean 0.9.1

Find and reclaim disk space on macOS
mod launchd;
mod period;

use anyhow::{Result, bail};
use serde::Serialize;

pub use period::{Every, parse_every};

/// Written into every plist we create. Bump when the on-disk job format
/// changes; [`is_maclean_job`] still has to recognize every older schema
/// we have ever shipped, or uninstall will leave orphans.
pub const JOB_SCHEMA: i64 = 1;

/// launchd Label / filename prefix. Not enough on its own to decide a
/// file is ours — see [`is_maclean_job`].
pub const LABEL_PREFIX: &str = "com.maclean.job.";

pub(crate) const MANAGED_KEY: &str = "MacleanManaged";
pub(crate) const SCHEMA_KEY: &str = "MacleanSchema";
pub(crate) const ITEM_KEY: &str = "MacleanItemId";
pub(crate) const COMMENT: &str =
    "Managed by maclean. Do not edit this file. Use the maclean TUI or: maclean schedule";

#[derive(Debug, Clone, Serialize)]
pub struct ScheduledJob {
    pub item_id: String,
    pub every: Every,
    /// Full argv that launchd will run, e.g. `["/opt/maclean", "reclaim", "spotify:cache", "--yes"]`.
    pub command: Vec<String>,
    /// 0 = written before schema keys existed; still ours if the comment matches.
    pub schema: i64,
}

pub trait Scheduler {
    fn list(&self) -> Result<Vec<ScheduledJob>>;
    fn add(&self, job: &ScheduledJob) -> Result<()>;
    fn remove(&self, item_id: &str) -> Result<()>;
    /// Unload and delete every job we can prove we created. Leaves every
    /// other LaunchAgent alone.
    fn purge(&self) -> Result<Vec<ScheduledJob>>;
}

pub fn current() -> Box<dyn Scheduler> {
    Box::new(launchd::LaunchdScheduler)
}

pub fn maclean_command(item_id: &str) -> Result<Vec<String>> {
    if !valid_item_id(item_id) {
        bail!("invalid item id '{item_id}'");
    }
    let exe = std::env::current_exe()?;
    Ok(vec![
        exe.display().to_string(),
        "reclaim".into(),
        item_id.into(),
        "--yes".into(),
    ])
}

/// Item ids come from our tree or from `schedule add`. They are argv, not
/// a shell string, but we still reject anything that looks like a flag
/// or has characters we would not write ourselves.
pub fn valid_item_id(id: &str) -> bool {
    !id.is_empty()
        && !id.starts_with('-')
        && id
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '.' | '-' | '_' | '/' | '+'))
}

/// Decide whether a LaunchAgent is one we created.
///
/// Filename prefix is the first filter (so we never open every plist in
/// LaunchAgents). After that we require our Label prefix *and* one of:
/// - schema 1+: `MacleanManaged` / `MacleanSchema`
/// - schema 0: Comment starts with "Managed by maclean"
///
/// A file that only happens to be named `com.maclean.job.*` is not enough.
pub fn is_maclean_job(
    filename: &str,
    label: Option<&str>,
    comment: Option<&str>,
    schema: Option<i64>,
    managed: Option<bool>,
) -> bool {
    if !filename.starts_with(LABEL_PREFIX) || !filename.ends_with(".plist") {
        return false;
    }
    let Some(label) = label else {
        return false;
    };
    if !label.starts_with(LABEL_PREFIX) {
        return false;
    }
    if managed == Some(true) || schema.is_some_and(|s| s >= 1) {
        return true;
    }
    comment.is_some_and(|c| c.starts_with("Managed by maclean"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn item_ids_reject_flags_and_junk() {
        assert!(valid_item_id("spotify:cache"));
        assert!(valid_item_id("cargo:/Users/a/proj"));
        assert!(valid_item_id("docker:image:sha256:abc"));
        assert!(!valid_item_id(""));
        assert!(!valid_item_id("--yes"));
        assert!(!valid_item_id("foo;rm"));
        assert!(!valid_item_id("foo bar"));
    }

    #[test]
    fn foreign_plists_are_not_ours_even_with_our_filename() {
        assert!(!is_maclean_job(
            "com.maclean.job.evil.plist",
            Some("com.apple.something"),
            Some("Managed by maclean. Do not edit this file."),
            None,
            None,
        ));
        assert!(!is_maclean_job(
            "com.maclean.job.evil.plist",
            Some("com.maclean.job.evil"),
            Some("a random agent"),
            None,
            None,
        ));
        assert!(is_maclean_job(
            "com.maclean.job.spotify-cache.plist",
            Some("com.maclean.job.spotify-cache"),
            Some("Managed by maclean. Do not edit this file."),
            None,
            None,
        ));
        assert!(is_maclean_job(
            "com.maclean.job.spotify-cache.plist",
            Some("com.maclean.job.spotify-cache"),
            None,
            Some(1),
            Some(true),
        ));
    }
}