fux 0.2.0

Agent-native terminal workspace
Documentation
use std::env;
use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DaemonPaths {
    pub runtime_dir: PathBuf,
    pub state_dir: PathBuf,
    pub manager_socket: PathBuf,
    pub descriptors_dir: PathBuf,
    pub keys_dir: PathBuf,
}

impl DaemonPaths {
    pub fn discover() -> Result<Self, PathError> {
        Self::from_env(
            env::var_os("XDG_RUNTIME_DIR"),
            env::var_os("XDG_STATE_HOME"),
            env::var_os("HOME"),
        )
    }

    pub fn from_env(
        runtime: Option<OsString>,
        state: Option<OsString>,
        home: Option<OsString>,
    ) -> Result<Self, PathError> {
        let runtime = absolute(runtime)
            .or_else(|| macos_runtime_fallback(home.as_ref()))
            .ok_or(PathError::MissingRuntime)?;
        let state = absolute(state)
            .or_else(|| absolute(home).map(|path| path.join(".local/state")))
            .ok_or(PathError::MissingState)?;
        let runtime_dir = runtime.join("fux");
        let state_dir = state.join("fux");
        Ok(Self {
            manager_socket: runtime_dir.join("manager.sock"),
            descriptors_dir: runtime_dir.join("workspaces"),
            keys_dir: state_dir.join("keys"),
            runtime_dir,
            state_dir,
        })
    }

    pub fn prepare(&self) -> Result<(), PathError> {
        private_dir(&self.runtime_dir)?;
        private_dir(&self.state_dir)?;
        private_dir(&self.descriptors_dir)?;
        private_dir(&self.keys_dir)
    }

    pub fn descriptor(&self, name: &str) -> Result<PathBuf, PathError> {
        validate_workspace_name(name)?;
        Ok(self.descriptors_dir.join(format!("{name}.json")))
    }

    pub fn key(&self, name: &str) -> Result<PathBuf, PathError> {
        validate_workspace_name(name)?;
        Ok(self.keys_dir.join(format!("{name}.key")))
    }
}

#[cfg(target_os = "macos")]
fn macos_runtime_fallback(home: Option<&OsString>) -> Option<PathBuf> {
    absolute(home.cloned()).map(|path| path.join("Library/Caches/fux-runtime"))
}

#[cfg(not(target_os = "macos"))]
fn macos_runtime_fallback(_: Option<&OsString>) -> Option<PathBuf> {
    None
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PathError {
    MissingRuntime,
    MissingState,
    UnsafeName,
    UnsafeDirectory(PathBuf),
    Io(String),
}

impl fmt::Display for PathError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self:?}")
    }
}
impl std::error::Error for PathError {}

pub fn validate_workspace_name(name: &str) -> Result<(), PathError> {
    if name.is_empty()
        || name.len() > 64
        || name == "."
        || name == ".."
        || !name
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
    {
        return Err(PathError::UnsafeName);
    }
    Ok(())
}

fn absolute(value: Option<OsString>) -> Option<PathBuf> {
    value
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .filter(|path| path.is_absolute())
}

fn private_dir(path: &Path) -> Result<(), PathError> {
    match fs::symlink_metadata(path) {
        Ok(metadata) => {
            if !metadata.is_dir()
                || metadata.file_type().is_symlink()
                || metadata.permissions().mode() & 0o077 != 0
            {
                return Err(PathError::UnsafeDirectory(path.to_owned()));
            }
            if let Some(parent) = path.parent()
                && let Ok(parent_metadata) = fs::metadata(parent)
                && metadata.uid() != parent_metadata.uid()
            {
                return Err(PathError::UnsafeDirectory(path.to_owned()));
            }
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            fs::create_dir_all(path).map_err(|error| PathError::Io(error.to_string()))?;
            fs::set_permissions(path, fs::Permissions::from_mode(0o700))
                .map_err(|error| PathError::Io(error.to_string()))?;
        }
        Err(error) => return Err(PathError::Io(error.to_string())),
    }
    Ok(())
}