microsandbox-utils 0.2.6

`microsandbox-utils` is a library containing general utilities for the microsandbox project.
Documentation
//! `microsandbox_utils::path` is a module containing path utilities for the microsandbox project.

use std::{
    path::{Path, PathBuf},
    sync::LazyLock,
};

use typed_path::{Utf8UnixComponent, Utf8UnixPathBuf};

use crate::{MicrosandboxUtilsError, MicrosandboxUtilsResult};

//--------------------------------------------------------------------------------------------------
// Constants
//--------------------------------------------------------------------------------------------------

/// The directory name for microsandbox's project-specific data
pub const MICROSANDBOX_ENV_DIR: &str = ".menv";

/// The directory name for microsandbox's global data
pub const MICROSANDBOX_HOME_DIR: &str = ".microsandbox";

/// The directory where project read-write layers are stored
///
/// Example: <PROJECT_ROOT>/<MICROSANDBOX_ENV_DIR>/<RW_SUBDIR>
pub const RW_SUBDIR: &str = "rw";

/// The directory where project patch layers are stored
///
/// Example: <PROJECT_ROOT>/<MICROSANDBOX_ENV_DIR>/<PATCH_SUBDIR>
pub const PATCH_SUBDIR: &str = "patch";

/// The directory where project logs are stored
///
/// Example: <PROJECT_ROOT>/<MICROSANDBOX_ENV_DIR>/<LOG_SUBDIR>
pub const LOG_SUBDIR: &str = "log";

/// The directory where global image layers are stored
///
/// Example: <MICROSANDBOX_HOME_DIR>/<LAYERS_SUBDIR>
pub const LAYERS_SUBDIR: &str = "layers";

/// The directory where installed sandboxes are stored
///
/// Example: <MICROSANDBOX_HOME_DIR>/<INSTALLS_SUBDIR>
pub const INSTALLS_SUBDIR: &str = "installs";

/// The filename for the project active sandbox database
///
/// Example: <PROJECT_ROOT>/<MICROSANDBOX_ENV_DIR>/<SANDBOX_DB_FILENAME>
pub const SANDBOX_DB_FILENAME: &str = "sandbox.db";

/// The filename for the global OCI database
///
/// Example: <MICROSANDBOX_HOME_DIR>/<OCI_DB_FILENAME>
pub const OCI_DB_FILENAME: &str = "oci.db";

/// The directory on the microvm where sandbox scripts are stored
pub const SANDBOX_DIR: &str = ".sandbox";

/// The directory on the microvm where sandbox scripts are stored
///
/// Example: <SANDBOX_DIR>/<SCRIPTS_DIR>
pub const SCRIPTS_DIR: &str = "scripts";

/// The suffix added to extracted layer directories
///
/// Example: <MICROSANDBOX_HOME_DIR>/<LAYERS_SUBDIR>/<LAYER_ID>.<EXTRACTED_LAYER_SUFFIX>
pub const EXTRACTED_LAYER_SUFFIX: &str = "extracted";

/// The microsandbox config file name.
///
/// Example: <PROJECT_ROOT>/<MICROSANDBOX_ENV_DIR>/<SANDBOX_DB_FILENAME>
pub const MICROSANDBOX_CONFIG_FILENAME: &str = "Sandboxfile";

/// The shell script name.
///
/// Example: <PROJECT_ROOT>/<MICROSANDBOX_ENV_DIR>/<PATCH_SUBDIR>/<CONFIG_NAME>/<SHELL_SCRIPT_NAME>
pub const SHELL_SCRIPT_NAME: &str = "shell";

/// The directory where namespaces are stored
///
/// Example: <MICROSANDBOX_HOME_DIR>/<NAMESPACES_SUBDIR>
pub const NAMESPACES_SUBDIR: &str = "namespaces";

/// The PID file for the server
///
/// Example: <MICROSANDBOX_HOME_DIR>/<SERVER_PID_FILE>
pub const SERVER_PID_FILE: &str = "server.pid";

/// The server secret key file
///
/// Example: <MICROSANDBOX_HOME_DIR>/<SERVER_KEY_FILE>
pub const SERVER_KEY_FILE: &str = "server.key";

/// The file where sandbox portal ports are stored
///
/// Example: <MICROSANDBOX_HOME_DIR>/<NAMESPACE_SUBDIR>/<PORTAL_PORTS_FILE>
pub const PORTAL_PORTS_FILE: &str = "portal.ports";

/// The XDG home directory
///
/// Example: <HOME>/.local
pub static XDG_HOME_DIR: LazyLock<PathBuf> =
    LazyLock::new(|| dirs::home_dir().unwrap().join(".local"));

/// The bin subdirectory for microsandbox
///
/// Example: <XDG_HOME_DIR>/bin
pub const XDG_BIN_DIR: &str = "bin";

/// The lib subdirectory for microsandbox
///
/// Example: <XDG_HOME_DIR>/lib
pub const XDG_LIB_DIR: &str = "lib";

/// The suffix for log files
pub const LOG_SUFFIX: &str = "log";

/// The filename for the supervisor's log file
pub const SUPERVISOR_LOG_FILENAME: &str = "supervisor.log";

//--------------------------------------------------------------------------------------------------
// Types
//--------------------------------------------------------------------------------------------------

/// The type of a supported path.
pub enum SupportedPathType {
    /// Any path type.
    Any,

    /// An absolute path.
    Absolute,

    /// A relative path.
    Relative,
}

//--------------------------------------------------------------------------------------------------
// Functions
//--------------------------------------------------------------------------------------------------

/// Normalizes a path string for volume mount comparison.
///
/// Rules:
/// - Resolves . and .. components where possible
/// - Prevents path traversal that would escape the root
/// - Removes redundant separators and trailing slashes
/// - Case-sensitive comparison (Unix standard)
/// - Can enforce path type requirements (absolute, relative, or any)
///
/// # Arguments
/// * `path` - The path to normalize
/// * `path_type` - The required path type (absolute, relative, or any)
///
/// # Returns
/// An error if the path is invalid, would escape root, or doesn't meet path type requirement
pub fn normalize_path(path: &str, path_type: SupportedPathType) -> MicrosandboxUtilsResult<String> {
    if path.is_empty() {
        return Err(MicrosandboxUtilsError::PathValidation(
            "Path cannot be empty".to_string(),
        ));
    }

    let path = Utf8UnixPathBuf::from(path);
    let mut normalized = Vec::new();
    let mut is_absolute = false;
    let mut depth = 0;

    for component in path.components() {
        match component {
            // Root component must come first if present
            Utf8UnixComponent::RootDir => {
                if normalized.is_empty() {
                    is_absolute = true;
                    normalized.push("/".to_string());
                } else {
                    return Err(MicrosandboxUtilsError::PathValidation(
                        "Invalid path: root component '/' found in middle of path".to_string(),
                    ));
                }
            }
            // Handle parent directory references
            Utf8UnixComponent::ParentDir => {
                if depth > 0 {
                    // Can go up if we have depth
                    normalized.pop();
                    depth -= 1;
                } else {
                    // Trying to go above root
                    return Err(MicrosandboxUtilsError::PathValidation(
                        "Invalid path: cannot traverse above root directory".to_string(),
                    ));
                }
            }
            // Skip current dir components
            Utf8UnixComponent::CurDir => continue,
            // Normal components are fine
            Utf8UnixComponent::Normal(c) => {
                if !c.is_empty() {
                    normalized.push(c.to_string());
                    depth += 1;
                }
            }
        }
    }

    // Check path type requirements
    match path_type {
        SupportedPathType::Absolute if !is_absolute => {
            return Err(MicrosandboxUtilsError::PathValidation(
                "Path must be absolute (start with '/')".to_string(),
            ));
        }
        SupportedPathType::Relative if is_absolute => {
            return Err(MicrosandboxUtilsError::PathValidation(
                "Path must be relative (must not start with '/')".to_string(),
            ));
        }
        _ => {}
    }

    if is_absolute {
        if normalized.len() == 1 {
            // Just root
            Ok("/".to_string())
        } else {
            // Join all components with "/" and add root at start
            Ok(format!("/{}", normalized[1..].join("/")))
        }
    } else {
        // For relative paths, just join all components
        Ok(normalized.join("/"))
    }
}

/// Resolves the path to a file, checking both environment variable and default locations.
///
/// First checks the environment variable specified by `env_var`.
/// If that's not set, falls back to `default_path`.
/// Returns an error if the file is not found at the resolved location.
pub fn resolve_env_path(
    env_var: &str,
    default_path: impl AsRef<Path>,
) -> MicrosandboxUtilsResult<PathBuf> {
    let (path, source) = std::env::var(env_var)
        .map(|p| (PathBuf::from(p), "environment variable"))
        .unwrap_or_else(|_| (default_path.as_ref().to_path_buf(), "default path"));

    if !path.exists() {
        return Err(MicrosandboxUtilsError::FileNotFound(
            path.to_string_lossy().to_string(),
            source.to_string(),
        ));
    }

    Ok(path)
}

//--------------------------------------------------------------------------------------------------
// Tests
//--------------------------------------------------------------------------------------------------

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

    #[test]
    fn test_normalize_path() {
        // Test with SupportedPathType::Absolute
        assert_eq!(
            normalize_path("/data/app/", SupportedPathType::Absolute).unwrap(),
            "/data/app"
        );
        assert_eq!(
            normalize_path("/data//app", SupportedPathType::Absolute).unwrap(),
            "/data/app"
        );
        assert_eq!(
            normalize_path("/data/./app", SupportedPathType::Absolute).unwrap(),
            "/data/app"
        );

        // Test with SupportedPathType::Relative
        assert_eq!(
            normalize_path("data/app/", SupportedPathType::Relative).unwrap(),
            "data/app"
        );
        assert_eq!(
            normalize_path("./data/app", SupportedPathType::Relative).unwrap(),
            "data/app"
        );
        assert_eq!(
            normalize_path("data//app", SupportedPathType::Relative).unwrap(),
            "data/app"
        );

        // Test with SupportedPathType::Any
        assert_eq!(
            normalize_path("/data/app", SupportedPathType::Any).unwrap(),
            "/data/app"
        );
        assert_eq!(
            normalize_path("data/app", SupportedPathType::Any).unwrap(),
            "data/app"
        );

        // Path traversal within bounds
        assert_eq!(
            normalize_path("/data/temp/../app", SupportedPathType::Absolute).unwrap(),
            "/data/app"
        );
        assert_eq!(
            normalize_path("data/temp/../app", SupportedPathType::Relative).unwrap(),
            "data/app"
        );

        // Invalid paths
        assert!(matches!(
            normalize_path("data/app", SupportedPathType::Absolute),
            Err(MicrosandboxUtilsError::PathValidation(e)) if e.contains("must be absolute")
        ));
        assert!(matches!(
            normalize_path("/data/app", SupportedPathType::Relative),
            Err(MicrosandboxUtilsError::PathValidation(e)) if e.contains("must be relative")
        ));
        assert!(matches!(
            normalize_path("/data/../..", SupportedPathType::Any),
            Err(MicrosandboxUtilsError::PathValidation(e)) if e.contains("cannot traverse above root")
        ));
    }

    #[test]
    fn test_normalize_path_complex() {
        // Complex but valid paths
        assert_eq!(
            normalize_path(
                "/data/./temp/../logs/app/./config/../",
                SupportedPathType::Absolute
            )
            .unwrap(),
            "/data/logs/app"
        );
        assert_eq!(
            normalize_path(
                "/data///temp/././../app//./test/..",
                SupportedPathType::Absolute
            )
            .unwrap(),
            "/data/app"
        );

        // Edge cases
        assert_eq!(
            normalize_path("/data/./././.", SupportedPathType::Absolute).unwrap(),
            "/data"
        );
        assert_eq!(
            normalize_path("/data/test/../../data/app", SupportedPathType::Absolute).unwrap(),
            "/data/app"
        );

        // Invalid complex paths
        assert!(matches!(
            normalize_path("/data/test/../../../root", SupportedPathType::Any),
            Err(MicrosandboxUtilsError::PathValidation(e)) if e.contains("cannot traverse above root")
        ));
        assert!(matches!(
            normalize_path("/./data/../..", SupportedPathType::Any),
            Err(MicrosandboxUtilsError::PathValidation(e)) if e.contains("cannot traverse above root")
        ));
    }
}