aion-server 0.29.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! `aion store harden`: the one-time full walk over a store's tree.
//!
//! Until haematite 0.12.1, the library made no promise about the mode of the
//! files it created, so the server walked every object file under the data
//! root at every boot — twice — to `chmod` each one. On a store of 1.18 M
//! files that was 171 s of silence per boot. Files are now private from
//! their own creation, and the boot checks the data root and each shard
//! directory only. The walk survives here, as an operator verb, for a store
//! that predates that release or that someone loosened by hand: it applies
//! 0700 to every directory and 0600 to every file, refuses a symbolic link
//! anywhere in the tree, and narrates one line per top-level entry with the
//! running file count.
//!
//! It refuses to run while a live server holds the store's writer lock:
//! the walk is safe against a concurrent writer (a mode change on a file the
//! server is writing is harmless), but an operator running it expects the
//! store to be theirs, and a refusal that names the lock is the honest
//! answer. The lock is held for the walk's duration, so a server that starts
//! meanwhile waits on it, narrated by its own boot.

use std::fs::{File, TryLockError};
use std::io;
use std::path::{Path, PathBuf};

use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
#[cfg(unix)]
use cap_std::fs::PermissionsExt;
use cap_std::fs::{Dir, OpenOptions};

use crate::filesystem::{ConfinedDir, PRIVATE_DIR_MODE, PRIVATE_FILE_MODE, note_open};

/// The file haematite anchors its writer lock on, at the data root.
const WRITER_LOCK_FILE: &str = "writer.lock";

/// Why a hardening walk could not run or could not finish.
#[derive(Debug, thiserror::Error)]
pub enum HardenError {
    /// A live process holds the store's writer lock.
    #[error(
        "the store at `{data_dir}` is held by a live server: its writer lock at `{lock_path}` \
         is taken. Stop the server (`aion stop`), run this verb, then start it again — the \
         walk needs the store to itself"
    )]
    WriterLockHeld {
        /// The store that is held.
        data_dir: PathBuf,
        /// The lock that is taken.
        lock_path: PathBuf,
    },
    /// The writer lock exists but could not be opened or locked.
    #[error("could not take the store's writer lock at `{lock_path}`: {error}")]
    Lock {
        /// The lock file.
        lock_path: PathBuf,
        /// The underlying failure.
        #[source]
        error: io::Error,
    },
    /// The data root could not be acquired as a private root.
    #[error("could not open the store at `{data_dir}`: {error}")]
    Open {
        /// The store.
        data_dir: PathBuf,
        /// The underlying failure.
        #[source]
        error: io::Error,
    },
    /// A directory or file under the root could not be brought to its mode.
    #[error("hardening `{entry}` under `{data_dir}` failed: {error}")]
    Walk {
        /// The store.
        data_dir: PathBuf,
        /// The top-level entry being walked when it failed.
        entry: String,
        /// The underlying failure.
        #[source]
        error: io::Error,
    },
}

/// What one walk did.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct HardenReport {
    /// Directories brought to 0700, the root included.
    pub directories: u64,
    /// Files brought to 0600.
    pub files: u64,
}

/// One narrated step: a top-level entry finished, with the files it held and
/// the running total.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HardenProgress {
    /// The entry's name under the data root (`shard-17`, `config.json`).
    pub entry: String,
    /// Files brought to 0600 under this entry (1 for a file).
    pub files: u64,
    /// Files brought to 0600 so far, this entry included.
    pub total_files: u64,
}

/// Walk the whole store under `data_dir`, applying 0700 to every directory
/// and 0600 to every file, narrating each top-level entry through `progress`.
///
/// # Errors
///
/// Refuses with [`HardenError::WriterLockHeld`] while a live server holds the
/// store; otherwise surfaces the first filesystem failure, naming the
/// top-level entry it happened under. A symbolic link anywhere in the tree is
/// a failure — sensitive state never contains one.
pub fn harden_store_tree(
    data_dir: &Path,
    mut progress: impl FnMut(&HardenProgress),
) -> Result<HardenReport, HardenError> {
    let _held = take_writer_lock(data_dir)?;
    let root = ConfinedDir::open(data_dir).map_err(|error| HardenError::Open {
        data_dir: data_dir.to_path_buf(),
        error,
    })?;
    let mut report = HardenReport {
        directories: 1,
        files: 0,
    };
    let mut walk = |report: &mut HardenReport| -> io::Result<()> {
        let mut entries = Vec::new();
        for entry in root.dir().entries()? {
            let entry = entry?;
            entries.push((entry.file_name(), entry.file_type()?));
        }
        entries.sort_by(|left, right| left.0.cmp(&right.0));
        for (name, file_type) in entries {
            let entry = name.to_string_lossy().into_owned();
            let files_before = report.files;
            harden_entry(root.dir(), &name, file_type, report)
                .map_err(|error| io::Error::new(error.kind(), format!("{entry}: {error}")))?;
            progress(&HardenProgress {
                entry,
                files: report.files - files_before,
                total_files: report.files,
            });
        }
        Ok(())
    };
    walk(&mut report).map_err(|error| {
        let (entry, error) = split_entry(error);
        HardenError::Walk {
            data_dir: data_dir.to_path_buf(),
            entry,
            error,
        }
    })?;
    Ok(report)
}

/// The walk's errors are prefixed `entry: ` by the closure above so the
/// top-level entry survives into the typed error; this splits them apart
/// again without a second error type.
fn split_entry(error: io::Error) -> (String, io::Error) {
    let text = error.to_string();
    match text.split_once(": ") {
        Some((entry, rest)) => (
            entry.to_owned(),
            io::Error::new(error.kind(), rest.to_owned()),
        ),
        None => (String::new(), error),
    }
}

/// Take the store's writer lock for the walk. A store that has never been
/// opened has no lock file and nothing that could hold it, so `None`.
fn take_writer_lock(data_dir: &Path) -> Result<Option<File>, HardenError> {
    let lock_path = data_dir.join(WRITER_LOCK_FILE);
    let file = match File::options().read(true).write(true).open(&lock_path) {
        Ok(file) => file,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(HardenError::Lock { lock_path, error }),
    };
    match file.try_lock() {
        Ok(()) => Ok(Some(file)),
        Err(TryLockError::WouldBlock) => Err(HardenError::WriterLockHeld {
            data_dir: data_dir.to_path_buf(),
            lock_path,
        }),
        Err(TryLockError::Error(error)) => Err(HardenError::Lock { lock_path, error }),
    }
}

fn harden_entry(
    parent: &Dir,
    name: &std::ffi::OsStr,
    file_type: cap_std::fs::FileType,
    report: &mut HardenReport,
) -> io::Result<()> {
    if file_type.is_symlink() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "sensitive state contains a symbolic link",
        ));
    }
    if file_type.is_dir() {
        note_open();
        let child = parent.open_dir_nofollow(name)?;
        harden_dir(&child, report)
    } else if file_type.is_file() {
        harden_file(parent, name, report)
    } else {
        Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "sensitive state contains something that is neither a file nor a directory",
        ))
    }
}

fn harden_dir(dir: &Dir, report: &mut HardenReport) -> io::Result<()> {
    #[cfg(unix)]
    dir.set_permissions(
        Path::new("."),
        cap_std::fs::Permissions::from_mode(PRIVATE_DIR_MODE),
    )?;
    report.directories += 1;
    for entry in dir.entries()? {
        let entry = entry?;
        let name = entry.file_name();
        let file_type = entry.file_type()?;
        harden_entry(dir, &name, file_type, report).map_err(|error| {
            io::Error::new(error.kind(), format!("{}: {error}", name.to_string_lossy()))
        })?;
    }
    Ok(())
}

fn harden_file(parent: &Dir, name: &std::ffi::OsStr, report: &mut HardenReport) -> io::Result<()> {
    let mut options = OpenOptions::new();
    options.read(true).follow(FollowSymlinks::No);
    note_open();
    let file = parent.open_with(name, &options)?;
    #[cfg(unix)]
    file.set_permissions(cap_std::fs::Permissions::from_mode(PRIVATE_FILE_MODE))?;
    #[cfg(not(unix))]
    drop(file);
    report.files += 1;
    Ok(())
}

#[cfg(test)]
#[path = "store_harden_tests.rs"]
mod tests;