alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Atomic file writes under workspace policy.

use super::{
    config::FilesystemConfig,
    error::FileWriteError,
    path::{resolve_buffer_path, resolve_buffer_path_from},
};
use std::{
    ffi::{OsStr, OsString},
    fs::{self, File, Metadata},
    io::{self, Write},
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

#[cfg(unix)]
use super::platform::open_workspace_directory;
#[cfg(not(unix))]
use super::platform::require_current_path_identity;
#[cfg(unix)]
use rustix::fs::{Mode, OFlags, fchmod, fsync, openat, renameat, unlinkat};
#[cfg(not(unix))]
use std::fs::OpenOptions;

/// Writes bytes to a policy-accepted local file using a temp-file-and-rename strategy.
///
/// # Errors
///
/// Returns [`FileWriteError`] when the path is denied, an existing target is unsupported, or a
/// filesystem operation fails.
pub fn atomic_write(
    requested_path: impl AsRef<Path>,
    bytes: &[u8],
    config: &FilesystemConfig,
) -> Result<PathBuf, FileWriteError> {
    let resolved =
        resolve_buffer_path(requested_path.as_ref(), config).map_err(FileWriteError::Policy)?;
    write_resolved_path(resolved.path, bytes, config, resolved.exists)
}

/// Writes bytes to a workspace-relative file using the same atomic policy as [`atomic_write`].
///
/// Relative paths are resolved from [`FilesystemConfig::workspace_root`], not the process working
/// directory. Plugin workspace writes use this boundary after capability authorization.
///
/// # Errors
///
/// Returns [`FileWriteError`] when path policy rejects the request, an existing target is
/// unsupported, or a filesystem operation fails.
pub fn atomic_write_workspace_file(
    workspace_relative_path: impl AsRef<Path>,
    bytes: &[u8],
    config: &FilesystemConfig,
) -> Result<PathBuf, FileWriteError> {
    let resolved = resolve_buffer_path_from(
        workspace_relative_path.as_ref(),
        config,
        &config.workspace_root,
    )
    .map_err(FileWriteError::Policy)?;
    write_resolved_path(resolved.path, bytes, config, resolved.exists)
}

/// Writes bytes to a policy-resolved target path.
fn write_resolved_path(
    path: PathBuf,
    bytes: &[u8],
    config: &FilesystemConfig,
    exists: bool,
) -> Result<PathBuf, FileWriteError> {
    let parent = path
        .parent()
        .ok_or_else(|| FileWriteError::MissingParent { path: path.clone() })?;
    let temp_name = PathBuf::from(unique_temp_file_name(&path));
    let temp_path = parent.join(&temp_name);
    let write_result =
        write_temp_and_rename(config, parent, &temp_name, &temp_path, &path, bytes, exists);

    if write_result.is_err() {
        cleanup_temp_file(config, parent, &temp_name, &temp_path);
    }

    write_result?;
    Ok(path)
}

/// Writes `bytes` to `temp_path`, syncs, renames, and syncs the parent directory.
#[cfg(unix)]
fn write_temp_and_rename(
    config: &FilesystemConfig,
    parent: &Path,
    temp_name: &Path,
    temp_path: &Path,
    target_path: &Path,
    bytes: &[u8],
    target_exists: bool,
) -> Result<(), FileWriteError> {
    let target_name =
        target_path
            .file_name()
            .map(Path::new)
            .ok_or_else(|| FileWriteError::MissingParent {
                path: target_path.to_owned(),
            })?;
    let relative_parent = parent
        .strip_prefix(&config.workspace_root)
        .map_err(|_error| FileWriteError::CreateTemp {
            path: temp_path.to_owned(),
            source: io::Error::new(
                io::ErrorKind::PermissionDenied,
                "target parent is outside workspace root",
            ),
        })?;
    let parent_dir = open_workspace_directory(relative_parent, config).map_err(|source| {
        FileWriteError::CreateTemp {
            path: temp_path.to_owned(),
            source,
        }
    })?;
    let existing_metadata = if target_exists {
        let metadata = existing_file_metadata_at(&parent_dir, target_name, target_path)?;
        if !metadata.is_file() {
            return Err(FileWriteError::UnsupportedFileType {
                path: target_path.to_owned(),
            });
        }
        Some(metadata)
    } else {
        None
    };
    let mut temp_file = create_temp_file_at(
        &parent_dir,
        temp_name,
        temp_path,
        existing_metadata.as_ref(),
    )?;
    temp_file
        .write_all(bytes)
        .map_err(|source| FileWriteError::WriteTemp {
            path: temp_path.to_owned(),
            source,
        })?;
    temp_file
        .sync_all()
        .map_err(|source| FileWriteError::SyncTemp {
            path: temp_path.to_owned(),
            source,
        })?;
    drop(temp_file);

    renameat(&parent_dir, temp_name, &parent_dir, target_name).map_err(|source| {
        FileWriteError::Rename {
            temp_path: temp_path.to_owned(),
            target_path: target_path.to_owned(),
            source: io::Error::from(source),
        }
    })?;
    fsync(&parent_dir).map_err(|source| FileWriteError::SyncTemp {
        path: parent.to_owned(),
        source: io::Error::from(source),
    })?;

    Ok(())
}

/// Reads existing target metadata through an already-open parent directory.
#[cfg(unix)]
fn existing_file_metadata_at(
    parent_dir: &File,
    target_name: &Path,
    target_path: &Path,
) -> Result<Metadata, FileWriteError> {
    let target_fd = openat(
        parent_dir,
        target_name,
        OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
        Mode::empty(),
    )
    .map_err(|source| FileWriteError::Metadata {
        path: target_path.to_owned(),
        source: io::Error::from(source),
    })?;
    let target_file = File::from(target_fd);
    target_file
        .metadata()
        .map_err(|source| FileWriteError::Metadata {
            path: target_path.to_owned(),
            source,
        })
}

/// Writes `bytes` to `temp_path`, syncs, renames, and best-effort syncs the parent directory.
#[cfg(not(unix))]
fn write_temp_and_rename(
    config: &FilesystemConfig,
    _parent: &Path,
    _temp_name: &Path,
    temp_path: &Path,
    target_path: &Path,
    bytes: &[u8],
    target_exists: bool,
) -> Result<(), FileWriteError> {
    let existing_metadata = if target_exists {
        let metadata = fs::metadata(target_path).map_err(|source| FileWriteError::Metadata {
            path: target_path.to_owned(),
            source,
        })?;
        if !metadata.is_file() {
            return Err(FileWriteError::UnsupportedFileType {
                path: target_path.to_owned(),
            });
        }
        require_current_path_identity(target_path, &metadata, config).map_err(|source| {
            FileWriteError::Metadata {
                path: target_path.to_owned(),
                source,
            }
        })?;
        Some(metadata)
    } else {
        None
    };
    let mut temp_file = create_temp_file(temp_path, existing_metadata.as_ref())?;
    temp_file
        .write_all(bytes)
        .map_err(|source| FileWriteError::WriteTemp {
            path: temp_path.to_owned(),
            source,
        })?;
    temp_file
        .sync_all()
        .map_err(|source| FileWriteError::SyncTemp {
            path: temp_path.to_owned(),
            source,
        })?;
    drop(temp_file);

    if let Some(metadata) = existing_metadata.as_ref() {
        require_current_path_identity(target_path, metadata, config).map_err(|source| {
            FileWriteError::Metadata {
                path: target_path.to_owned(),
                source,
            }
        })?;
    }

    fs::rename(temp_path, target_path).map_err(|source| FileWriteError::Rename {
        temp_path: temp_path.to_owned(),
        target_path: target_path.to_owned(),
        source,
    })?;

    if let Some(parent) = target_path.parent() {
        let _synced_parent = File::open(parent).and_then(|file| file.sync_all());
    }

    Ok(())
}

/// Creates a temporary file relative to an already-open parent directory.
#[cfg(unix)]
fn create_temp_file_at(
    parent_dir: &File,
    temp_name: &Path,
    temp_path: &Path,
    existing_metadata: Option<&Metadata>,
) -> Result<File, FileWriteError> {
    use std::os::unix::fs::PermissionsExt;

    let mode = existing_metadata.map_or(0o600, |metadata| metadata.permissions().mode() & 0o777);
    let mode = u16::try_from(mode).unwrap_or(0o600);
    let temp_fd = openat(
        parent_dir,
        temp_name,
        OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
        Mode::from_raw_mode(mode),
    )
    .map_err(|source| FileWriteError::CreateTemp {
        path: temp_path.to_owned(),
        source: io::Error::from(source),
    })?;
    if existing_metadata.is_some() {
        fchmod(&temp_fd, Mode::from_raw_mode(mode)).map_err(|source| {
            FileWriteError::SetTempPermissions {
                path: temp_path.to_owned(),
                source: io::Error::from(source),
            }
        })?;
    }

    Ok(File::from(temp_fd))
}

/// Removes a temporary file relative to an already-open parent directory.
#[cfg(unix)]
fn cleanup_temp_file(config: &FilesystemConfig, parent: &Path, temp_name: &Path, temp_path: &Path) {
    if let Ok(relative_parent) = parent.strip_prefix(&config.workspace_root)
        && let Ok(parent_dir) = open_workspace_directory(relative_parent, config)
    {
        let _removed = unlinkat(&parent_dir, temp_name, rustix::fs::AtFlags::empty());
    } else {
        let _removed = fs::remove_file(temp_path);
    }
}

/// Removes a temporary file after a failed non-Unix write.
#[cfg(not(unix))]
fn cleanup_temp_file(
    _config: &FilesystemConfig,
    _parent: &Path,
    _temp_name: &Path,
    temp_path: &Path,
) {
    let _removed = fs::remove_file(temp_path);
}

/// Creates a temporary file, preserving target permissions where the platform exposes them.
#[cfg(not(unix))]
fn create_temp_file(
    temp_path: &Path,
    existing_metadata: Option<&Metadata>,
) -> Result<File, FileWriteError> {
    let file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(temp_path)
        .map_err(|source| FileWriteError::CreateTemp {
            path: temp_path.to_owned(),
            source,
        })?;

    if let Some(metadata) = existing_metadata {
        file.set_permissions(metadata.permissions())
            .map_err(|source| FileWriteError::SetTempPermissions {
                path: temp_path.to_owned(),
                source,
            })?;
    }

    Ok(file)
}

/// Builds a unique temporary file name next to the target file.
fn unique_temp_file_name(target_path: &Path) -> OsString {
    let name = target_path
        .file_name()
        .unwrap_or_else(|| OsStr::new("buffer"));
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |duration| duration.as_nanos());

    OsString::from(format!(
        ".{}.alma-tmp-{}-{nanos}",
        name.to_string_lossy(),
        std::process::id()
    ))
}