lunar-lib 0.6.1

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

use crate::{error, warn};

pub(crate) static NULL_PATH: &str = "/dev/null";
pub(crate) const ILLEGAL_CHARACTERS: [(u8, char); 1] = [(b'/', '')];

pub(crate) fn sanitize_string(os_str: impl AsRef<OsStr>) -> OsString {
    let os_str = os_str.as_ref();
    let bytes = os_str.as_encoded_bytes();

    let mut buf = Vec::with_capacity(bytes.len());
    for byte in bytes {
        if let Some(replacement) = ILLEGAL_CHARACTERS
            .iter()
            .find_map(|(illegal, replacement)| (illegal == byte).then_some(replacement))
        {
            let mut char_buf = [0u8; 4];
            buf.extend(replacement.encode_utf8(&mut char_buf).as_bytes());
        } else {
            buf.push(*byte);
        }
    }

    // SAFETY: 'bytes' originates from a UTF-8 encoded string, bytes are only modified from a UTF-8 encoded char
    unsafe { OsString::from_encoded_bytes_unchecked(buf) }
}

/// 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 {
            11
        }
    });

    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(base_dirs) = directories::BaseDirs::new() {
            let home = base_dirs.home_dir();
            dirs.push(home.to_path_buf());
            dirs.push(home.join(".ssh"));
            dirs.push(home.join(".gnupg"));
            dirs.push(base_dirs.data_dir().to_path_buf());
            dirs.push(base_dirs.config_dir().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: Option<Vec<PathBuf>> =
        env::var_os("PROTECTED_DIRS").map(|v| env::split_paths(&v).collect());

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

    if let Some(user_paths) = user_paths {
        dirs.extend(user_paths);
    } else {
        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)
}

#[cfg(test)]
mod tests {
    #[test]
    fn sanitize_str_replaces_chars() {
        use std::ffi::OsString;

        use crate::paths::sys::sanitize_os_str;

        let os_str = OsString::from("/");
        let sanitized = sanitize_os_str(os_str);

        assert_eq!(sanitized, OsString::from(""))
    }
}