regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use std::{
    fmt,
    fs::{self, File, OpenOptions},
    io::{self, Read, Write},
    path::{Path, PathBuf},
};

#[cfg(test)]
use std::{cell::Cell, marker::PhantomData, rc::Rc};

#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};

use thiserror::Error;

const PRIVATE_FILE_MODE: u32 = 0o600;
const PRIVATE_DIRECTORY_MODE: u32 = 0o700;
const TEMPORARY_NAME_ATTEMPTS: usize = 8;

#[cfg(unix)]
#[derive(Clone, Copy, PartialEq, Eq)]
enum OwnershipTarget {
    ParentDirectory,
    KeyFile,
}

#[cfg(test)]
thread_local! {
    static REJECTED_OWNER_TARGET: Cell<Option<OwnershipTarget>> = const { Cell::new(None) };
}

#[cfg(test)]
pub(crate) struct OwnerMismatchGuard {
    previous: Option<OwnershipTarget>,
    _not_send: PhantomData<Rc<()>>,
}

#[cfg(test)]
impl Drop for OwnerMismatchGuard {
    fn drop(&mut self) {
        REJECTED_OWNER_TARGET.set(self.previous);
    }
}

type PublishKey = fn(&Path, &Path, &Path) -> rustix::io::Result<()>;

#[derive(Clone)]
pub(crate) struct IrohIdentity {
    #[allow(dead_code)] // Endpoint binding begins in the next transport task.
    secret_key: iroh::SecretKey,
    endpoint_id: iroh::EndpointId,
}

impl IrohIdentity {
    fn from_secret_key(secret_key: iroh::SecretKey) -> Self {
        let endpoint_id = secret_key.public();
        Self {
            secret_key,
            endpoint_id,
        }
    }

    pub(crate) fn endpoint_id(&self) -> iroh::EndpointId {
        self.endpoint_id
    }

    #[allow(dead_code)] // Endpoint binding begins in the next transport task.
    pub(crate) fn secret_key(&self) -> &iroh::SecretKey {
        &self.secret_key
    }
}

impl fmt::Debug for IrohIdentity {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("IrohIdentity")
            .field("endpoint_id", &self.endpoint_id)
            .finish()
    }
}

impl fmt::Display for IrohIdentity {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.endpoint_id.fmt(formatter)
    }
}

#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IrohIdentityError {
    #[error("iroh endpoint identity storage is invalid or unavailable")]
    Storage,
}

#[derive(Debug, Clone)]
pub(crate) struct IrohIdentityStore {
    path: PathBuf,
    publish_key: PublishKey,
}

impl IrohIdentityStore {
    pub(crate) fn new(path: impl Into<PathBuf>) -> Self {
        Self {
            path: path.into(),
            publish_key: publish_key_no_replace,
        }
    }

    #[cfg(test)]
    pub(crate) fn with_unsupported_atomic_publish(path: impl Into<PathBuf>) -> Self {
        Self {
            path: path.into(),
            publish_key: unsupported_atomic_publish,
        }
    }

    #[cfg(test)]
    pub(crate) fn reject_key_owner_for_test() -> OwnerMismatchGuard {
        reject_owner_for_test(OwnershipTarget::KeyFile)
    }

    #[cfg(test)]
    pub(crate) fn reject_parent_owner_for_test() -> OwnerMismatchGuard {
        reject_owner_for_test(OwnershipTarget::ParentDirectory)
    }

    pub(crate) fn load_or_create(&self) -> Result<IrohIdentity, IrohIdentityError> {
        let parent = ensure_private_parent(&self.path)?;
        if let Some(secret_key) = read_key(&self.path)? {
            return Ok(IrohIdentity::from_secret_key(secret_key));
        }

        let secret_key = iroh::SecretKey::generate();
        let (temporary_path, mut temporary_file) = create_temporary_key(parent)?;
        let write_result = (|| -> Result<(), IrohIdentityError> {
            temporary_file
                .set_permissions(private_file_permissions())
                .map_err(|_| IrohIdentityError::Storage)?;
            temporary_file
                .write_all(&secret_key.to_bytes())
                .map_err(|_| IrohIdentityError::Storage)?;
            temporary_file
                .sync_all()
                .map_err(|_| IrohIdentityError::Storage)
        })();
        if write_result.is_err() {
            let _ = fs::remove_file(&temporary_path);
            return Err(IrohIdentityError::Storage);
        }

        match (self.publish_key)(parent, &temporary_path, &self.path) {
            Ok(()) => {
                sync_directory(parent).map_err(|_| IrohIdentityError::Storage)?;
                read_key(&self.path)?
                    .map(IrohIdentity::from_secret_key)
                    .ok_or(IrohIdentityError::Storage)
            }
            Err(error) if error == rustix::io::Errno::EXIST => {
                let _ = fs::remove_file(&temporary_path);
                read_key(&self.path)?
                    .map(IrohIdentity::from_secret_key)
                    .ok_or(IrohIdentityError::Storage)
            }
            Err(_) => {
                let _ = fs::remove_file(&temporary_path);
                Err(IrohIdentityError::Storage)
            }
        }
    }

    /// Loads only an already-persisted endpoint identity.  Status and diagnostics use this
    /// path so inspecting public identity can never create or replace secret-key material.
    pub(crate) fn load_existing(&self) -> Result<Option<IrohIdentity>, IrohIdentityError> {
        if ensure_existing_private_parent(&self.path)?.is_none() {
            return Ok(None);
        }
        Ok(read_key(&self.path)?.map(IrohIdentity::from_secret_key))
    }
}

#[cfg(test)]
fn reject_owner_for_test(target: OwnershipTarget) -> OwnerMismatchGuard {
    OwnerMismatchGuard {
        previous: REJECTED_OWNER_TARGET.replace(Some(target)),
        _not_send: PhantomData,
    }
}

fn ensure_private_parent(path: &Path) -> Result<&Path, IrohIdentityError> {
    let parent = match path.parent() {
        Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
        Some(parent) => parent,
        None => return Err(IrohIdentityError::Storage),
    };
    match fs::symlink_metadata(parent) {
        Ok(metadata) => {
            if metadata.file_type().is_symlink()
                || !metadata.is_dir()
                || !private_directory(&metadata)
            {
                return Err(IrohIdentityError::Storage);
            }
        }
        Err(error) if error.kind() == io::ErrorKind::NotFound => {
            fs::create_dir_all(parent).map_err(|_| IrohIdentityError::Storage)?;
            fs::set_permissions(parent, private_directory_permissions())
                .map_err(|_| IrohIdentityError::Storage)?;
        }
        Err(_) => return Err(IrohIdentityError::Storage),
    }
    Ok(parent)
}

fn ensure_existing_private_parent(path: &Path) -> Result<Option<&Path>, IrohIdentityError> {
    let parent = match path.parent() {
        Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
        Some(parent) => parent,
        None => return Err(IrohIdentityError::Storage),
    };
    match fs::symlink_metadata(parent) {
        Ok(metadata) => {
            if metadata.file_type().is_symlink()
                || !metadata.is_dir()
                || !private_directory(&metadata)
            {
                Err(IrohIdentityError::Storage)
            } else {
                Ok(Some(parent))
            }
        }
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(_) => Err(IrohIdentityError::Storage),
    }
}

fn create_temporary_key(parent: &Path) -> Result<(PathBuf, File), IrohIdentityError> {
    for _ in 0..TEMPORARY_NAME_ATTEMPTS {
        let path = parent.join(format!(".iroh-endpoint-{}.tmp", uuid::Uuid::new_v4()));
        let mut options = OpenOptions::new();
        options.write(true).create_new(true).mode(PRIVATE_FILE_MODE);
        match options.open(&path) {
            Ok(file) => return Ok((path, file)),
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
            Err(_) => return Err(IrohIdentityError::Storage),
        }
    }
    Err(IrohIdentityError::Storage)
}

fn publish_key_no_replace(
    parent: &Path,
    temporary: &Path,
    destination: &Path,
) -> rustix::io::Result<()> {
    let directory = File::open(parent).map_err(|error| {
        rustix::io::Errno::from_io_error(&error).unwrap_or(rustix::io::Errno::IO)
    })?;
    let temporary_name = temporary.file_name().ok_or(rustix::io::Errno::INVAL)?;
    let destination_name = destination.file_name().ok_or(rustix::io::Errno::INVAL)?;
    rustix::fs::renameat_with(
        &directory,
        temporary_name,
        &directory,
        destination_name,
        rustix::fs::RenameFlags::NOREPLACE,
    )
}

#[cfg(test)]
fn unsupported_atomic_publish(
    _parent: &Path,
    _temporary: &Path,
    _destination: &Path,
) -> rustix::io::Result<()> {
    Err(rustix::io::Errno::NOSYS)
}

fn read_key(path: &Path) -> Result<Option<iroh::SecretKey>, IrohIdentityError> {
    let metadata = match fs::symlink_metadata(path) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(_) => return Err(IrohIdentityError::Storage),
    };
    if !valid_key_metadata(&metadata) {
        return Err(IrohIdentityError::Storage);
    }

    let mut options = OpenOptions::new();
    options
        .read(true)
        .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32);
    let mut file = options.open(path).map_err(|_| IrohIdentityError::Storage)?;
    let opened = file.metadata().map_err(|_| IrohIdentityError::Storage)?;
    if !valid_key_metadata(&opened) {
        return Err(IrohIdentityError::Storage);
    }

    let mut bytes = [0_u8; 32];
    file.read_exact(&mut bytes)
        .map_err(|_| IrohIdentityError::Storage)?;
    let mut extra = [0_u8; 1];
    if file
        .read(&mut extra)
        .map_err(|_| IrohIdentityError::Storage)?
        != 0
    {
        return Err(IrohIdentityError::Storage);
    }
    Ok(Some(iroh::SecretKey::from_bytes(&bytes)))
}

fn sync_directory(path: &Path) -> io::Result<()> {
    File::open(path)?.sync_all()
}

#[cfg(unix)]
fn private_directory(metadata: &fs::Metadata) -> bool {
    metadata.permissions().mode() & 0o777 == PRIVATE_DIRECTORY_MODE
        && owned_by_current_user(metadata, OwnershipTarget::ParentDirectory)
}

#[cfg(not(unix))]
fn private_directory(_metadata: &fs::Metadata) -> bool {
    true
}

#[cfg(unix)]
fn valid_key_metadata(metadata: &fs::Metadata) -> bool {
    !metadata.file_type().is_symlink()
        && metadata.is_file()
        && metadata.len() == 32
        && metadata.permissions().mode() & 0o777 == PRIVATE_FILE_MODE
        && owned_by_current_user(metadata, OwnershipTarget::KeyFile)
}

#[cfg(unix)]
fn owned_by_current_user(metadata: &fs::Metadata, target: OwnershipTarget) -> bool {
    #[cfg(test)]
    if REJECTED_OWNER_TARGET.get() == Some(target) {
        return false;
    }
    #[cfg(not(test))]
    let _ = target;
    metadata.uid() == rustix::process::getuid().as_raw()
}

#[cfg(not(unix))]
fn valid_key_metadata(metadata: &fs::Metadata) -> bool {
    !metadata.file_type().is_symlink() && metadata.is_file() && metadata.len() == 32
}

#[cfg(unix)]
fn private_file_permissions() -> fs::Permissions {
    fs::Permissions::from_mode(PRIVATE_FILE_MODE)
}

#[cfg(not(unix))]
fn private_file_permissions() -> fs::Permissions {
    fs::Permissions::readonly(false)
}

#[cfg(unix)]
fn private_directory_permissions() -> fs::Permissions {
    fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE)
}

#[cfg(not(unix))]
fn private_directory_permissions() -> fs::Permissions {
    fs::Permissions::readonly(false)
}