lunar-lib 0.1.0

Common utilities for lunar applications
Documentation
use std::{
    env, fs, io,
    os::unix::fs::MetadataExt,
    path::{Path, PathBuf},
    sync::LazyLock,
};

use crate::{error, paths::env_var_path, warn};

/// Returns the same as [`std::env::home_dir()`].
///
/// # Notes
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn home_dir() -> Option<&'static Path> {
    HOME.as_deref()
}
static HOME: LazyLock<Option<PathBuf>> = LazyLock::new(env::home_dir);

/// XDG_DATA_HOME standard
///
/// Prefers `$XDG_DATA_HOME`, but will fallback to `HOME/.local/share`
/// Returns [`None`] if `$XDG_CONFIG_HOME` is not set, [`std::env::home_dir()`] returns [`None`], or the path is relative
///
/// # Notes
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn xdg_data_home() -> Option<&'static Path> {
    XDG_DATA_HOME.as_deref()
}
static XDG_DATA_HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
    env_var_path("XDG_DATA_HOME")
        .or_else(|| env::home_dir().map(|h| h.join(".local/share")))
        .and_then(|a| if a.is_relative() { None } else { Some(a) })
});

/// XDG_CONFIG_HOME standard
///
/// Prefers `$XDG_CONFIG_HOME`, but will fallback to `HOME/.config`
/// Returns [`None`] if `$XDG_CONFIG_HOME` is not set, [`std::env::home_dir()`] returns [`None`], or the path is relative
///
/// # Notes
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn xdg_config_home() -> Option<&'static Path> {
    XDG_CONFIG_HOME.as_deref()
}
static XDG_CONFIG_HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
    env_var_path("XDG_CONFIG_HOME")
        .or_else(|| env::home_dir().map(|h| h.join(".config")))
        .and_then(|a| if a.is_relative() { None } else { Some(a) })
});

/// XDG_STATE_HOME standard
///
/// Prefers `$XDG_STATE_HOME`, but will fallback to `HOME/.local/state`
/// Returns [`None`] if `$XDG_STATE_HOME` is not set, [`std::env::home_dir()`] returns [`None`], or the path is relative
///
/// # Notes
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn xdg_state_home() -> Option<&'static Path> {
    XDG_STATE_HOME.as_deref()
}
static XDG_STATE_HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
    env_var_path("XDG_STATE_HOME")
        .or_else(|| env::home_dir().map(|h| h.join(".local/state")))
        .and_then(|a| if a.is_relative() { None } else { Some(a) })
});

/// XDG_CACHE_HOME standard
///
/// Prefers `$XDG_CACHE_HOME`, but will fallback to `HOME/.cache`
/// Returns [`None`] if `$XDG_CACHE_HOME` is not set, [`std::env::home_dir()`] returns [`None`], or the path is relative
///
/// # Notes
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn xdg_cache_home() -> Option<&'static Path> {
    XDG_CACHE_HOME.as_deref()
}
static XDG_CACHE_HOME: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
    env_var_path("XDG_CACHE_HOME")
        .or_else(|| env::home_dir().map(|h| h.join(".cache")))
        .and_then(|a| if a.is_relative() { None } else { Some(a) })
});

/// Returns all paths marked as explicitly protected. These paths should be treated carefully, like preventing deletion or moves
///
/// Protected paths are explicitly protected, meaning descendents of this path SHOULD NOT BE protected
/// If you would like to get all paths that have inexplict protections, see [`protected_directories()`]
///
/// # Notes
///
/// Users can specify their own protections using the environment variable `PROTECTED_PATHS`. Users can use the path 'default' to use additional default protections
/// If no environment variable is set, defaults will be used
///
/// The root directory will always be marked as protected regardless
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn protected_paths() -> &'static [PathBuf] {
    &PROTECTED_PATHS
}
static PROTECTED_PATHS: LazyLock<Box<[PathBuf]>> = LazyLock::new(|| {
    let user_paths: Option<Vec<PathBuf>> =
        env::var_os("PROTECTED_PATHS").map(|v| env::split_paths(&v).collect());

    let mut dirs = Vec::with_capacity({
        1 + if let Some(user_paths) = &user_paths {
            user_paths.len()
        } else {
            16
        }
    });

    dirs.push(PathBuf::from("/"));

    if let Some(user_paths) = user_paths {
        dirs.extend(user_paths);
    } else {
        dirs.push(PathBuf::from("/usr/bin"));
        dirs.push(PathBuf::from("/usr/lib"));
        dirs.push(PathBuf::from("/home"));
        dirs.push(PathBuf::from("/etc"));
        dirs.push(PathBuf::from("/sys"));

        if let Some(home) = env::home_dir() {
            dirs.push(home.join("Desktop"));
            dirs.push(home.join("Documents"));
            dirs.push(home.join("Music"));
            dirs.push(home.join("Pictures"));
            dirs.push(home.join("Videos"));
            dirs.push(home.join(".ssh"));
            dirs.push(home.join(".gnupg"));
            dirs.push(home);
        }

        if let Some(path) = xdg_config_home() {
            dirs.push(path.to_path_buf());
        };
        if let Some(path) = xdg_data_home() {
            dirs.push(path.to_path_buf());
        };
        if let Some(path) = xdg_state_home() {
            dirs.push(path.to_path_buf());
        }
    }

    dirs.into_boxed_slice()
});

/// Returns all paths marked as inexplicitly protected. These paths should be treated carefully, like preventing deletion or moves
///
/// Protected paths are inexplicitly protected, meaning descendents of this path SHOULD BE protected
/// If you would like to get all paths that have inexplict protections, see [`protected_directories()`]
///
/// # Notes
///
/// Users can specify their own protections using the environment variable `PROTECTED_DIRS`
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn protected_directories() -> &'static [PathBuf] {
    &PROTECTED_DIRECTORIES
}
static PROTECTED_DIRECTORIES: LazyLock<Box<[PathBuf]>> = LazyLock::new(|| {
    let user_paths: Vec<PathBuf> = env::var_os("PROTECTED_DIRS")
        .map(|v| env::split_paths(&v).collect())
        .unwrap_or_default();

    let mut dirs = Vec::with_capacity(
        user_paths.len()
            + if cfg!(feature = "extra-protected-paths") {
                1
            } else {
                0
            },
    );

    dirs.extend(user_paths);

    #[cfg(feature = "extra-protected-paths")]
    {
        dirs.push(PathBuf::from("/boot"));
    }

    dirs.into_boxed_slice()
});

/// Returns all device IDs (lstat) marked as unprotected.
///
/// Files on these devices should not be protected from filesystem protections like removing or renaming
///
/// # Notes
///
/// Users can specify their own protections using the environment variable `UNPROTECTED_DEVICES`
///
/// The value will be computed once and stored internally in a [`LazyLock`], any other call will return the cached result
pub fn unprotected_devices() -> &'static [u64] {
    &UNPROTECTED_DEVICES
}
static UNPROTECTED_DEVICES: LazyLock<Box<[u64]>> = LazyLock::new(|| {
    let user_devices = match env::var("UNPROTECTED_DEVICES") {
        Ok(v) => Some(v),
        Err(env::VarError::NotPresent) => None,
        Err(env::VarError::NotUnicode(os_string)) => {
            warn!(
                "UNPROTECTED_DEVICES is not readable, reverting to defaults. Reason: Expected UTF-8 string of devices ID's separated by ':', found: {os_string}",
                os_string = os_string.display()
            );
            None
        }
    };

    let unprotected_devices = user_devices
        .and_then(|s| {
            let parsed: Result<Vec<u64>, _> = s.split(':').map(|dev| dev.parse()).collect();

            if let Err(err) = &parsed {
                warn!(
                    "UNPROTECTED_DEVICES is not parseable, reverting to defaults. Reason: {err}"
                )
            }

            parsed.ok()
        })
        .unwrap_or_else(|| {
            ["/", "/home", "/tmp"].into_iter().filter_map(|path| match fs::symlink_metadata(path) {
                Ok(meta) => Some(meta.dev()),
                Err(err) => {
                    error!("Could not protect {path} by default: {err}{non_root}", non_root = if path != "/" {"\n(This is fine if '{path}' is not on a separate filesystem than '/')"} else {""});
                    None
                },
            }).collect()
        });

    unprotected_devices.into_boxed_slice()
});

/// Returns [`true`] if the input device is protected
///
/// The device is protected if is NOT contained inside [`unprotected_devices()`]
///
/// # Notes
///
/// This function does not actually read directly from [`unprotected_devices()`], for more info, see the function documentation
pub fn is_protected_device(device: u64) -> bool {
    !UNPROTECTED_DEVICES.contains(&device)
}

/// Gets the mountpoint(s) of a path by comparing its device to the devices of its parents
///
/// Returns the mountpoints in mount order
/// For example, if `/mnt/sda1/test` is input, assuming `/mnt/sda1` is a separate device, the function will return `[(/, </ st_dev>), (/mnt/sda1, </mnt/sda1 st_dev>)]`
///
/// For absolute paths, this function will always return atleast the drive root
///
/// # Notes
///
/// This function isn't getting exactly where something is mounted. It just checks for `current_device != parent_device`, which may not return correct results in all cases, but is usually correct for standard unix installations
pub fn get_mountpoints(path: impl AsRef<Path>) -> io::Result<Vec<(PathBuf, u64)>> {
    let path = path.as_ref();

    let mut mountpoints = Vec::new();
    let mut latest_dev = path.symlink_metadata()?.dev();
    let mut current = path;

    while let Some(parent) = current.parent() {
        let parent_meta = parent.symlink_metadata()?;
        if parent_meta.dev() != latest_dev {
            mountpoints.push((current.to_owned(), latest_dev));
            latest_dev = parent_meta.dev();
        }
        current = parent
    }

    if path.is_absolute() {
        mountpoints.push((current.to_owned(), latest_dev));
    }

    mountpoints.reverse();
    Ok(mountpoints)
}