lazaret/crates/lazaret-ignored-workspace/src/sentry.rs

355 lines
11 KiB
Rust
Raw Normal View History

use std::ffi::OsString;
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use rustix::fs::{FileType, Mode, OFlags, fstat};
use rustix::mount::{
MountFlags, MoveMountFlags, OpenTreeFlags, UnmountFlags, mount_remount, move_mount, open_tree,
unmount,
};
use crate::backing::BackingRoot;
#[derive(Clone, Debug)]
pub struct GitSentryConfiguration {
pub mounted_workspace: PathBuf,
pub host_git_common_directory: PathBuf,
pub transient_hooks: PathBuf,
pub transient_config: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitSentryLaunch {
arguments: [PathBuf; 4],
}
impl GitSentryLaunch {
#[must_use]
pub fn new(configuration: GitSentryConfiguration) -> Self {
Self {
arguments: [
configuration.mounted_workspace,
configuration.host_git_common_directory,
configuration.transient_hooks,
configuration.transient_config,
],
}
}
/// # Errors
/// Refuses any argument count other than the exact Git Sentry launch shape.
pub fn parse(arguments: Vec<OsString>) -> io::Result<Self> {
let arguments: [OsString; 4] = arguments.try_into().map_err(|_| {
io::Error::other(
"usage: lazaret-git-sentry MOUNTED_WORKSPACE HOST_GIT_COMMON_DIRECTORY TRANSIENT_HOOKS TRANSIENT_CONFIG",
)
})?;
Ok(Self {
arguments: arguments.map(PathBuf::from),
})
}
#[must_use]
pub fn configuration(&self) -> GitSentryConfiguration {
GitSentryConfiguration {
mounted_workspace: self.arguments[0].clone(),
host_git_common_directory: self.arguments[1].clone(),
transient_hooks: self.arguments[2].clone(),
transient_config: self.arguments[3].clone(),
}
}
#[must_use]
pub fn arguments(&self) -> [OsString; 4] {
self.arguments.clone().map(PathBuf::into_os_string)
}
}
pub struct GitSentry {
config: InstalledMount,
hooks: InstalledMount,
}
struct InstalledMount {
mounted_target: File,
cleanup_target: File,
cleanup_label: PathBuf,
}
impl InstalledMount {
fn unmount(self) -> io::Result<()> {
descriptor_move(&self.mounted_target, &self.cleanup_target)?;
unmount(self.cleanup_label, UnmountFlags::DETACH).map_err(Into::into)
}
}
impl GitSentry {
/// # Errors
/// Refuses linked or missing sources and targets and incomplete protective mounts.
pub fn install(configuration: &GitSentryConfiguration) -> io::Result<Self> {
let workspace = BackingRoot::open(&configuration.mounted_workspace)?;
let common = BackingRoot::open(&configuration.host_git_common_directory)?;
let transient_hooks = BackingRoot::open(&configuration.transient_hooks)?;
let transient_config = File::options()
.read(true)
.write(true)
.open(&configuration.transient_config)?;
Self::install_from_roots(
&workspace,
&common,
&transient_hooks,
configuration.transient_hooks.clone(),
transient_config,
configuration.transient_config.clone(),
)
}
/// # Errors
/// Refuses aliased, linked, or incomplete authorities and incomplete protective mounts.
pub fn install_from_authorities(
mounted_workspace: File,
host_git_common_directory: File,
transient_hooks: File,
transient_hooks_label: PathBuf,
transient_config: File,
transient_config_label: PathBuf,
) -> io::Result<Self> {
let workspace = BackingRoot::from_file(mounted_workspace)?;
let common = BackingRoot::from_file(host_git_common_directory)?;
let transient_hooks = BackingRoot::from_file(transient_hooks)?;
Self::install_from_roots(
&workspace,
&common,
&transient_hooks,
transient_hooks_label,
transient_config,
transient_config_label,
)
}
fn install_from_roots(
_workspace: &BackingRoot,
common: &BackingRoot,
transient_hooks: &BackingRoot,
transient_hooks_label: PathBuf,
transient_config: File,
transient_config_label: PathBuf,
) -> io::Result<Self> {
require_empty_regular_file(&transient_config)
.map_err(|error| sentry_error("validating transient config", &error))?;
let config_source = common
.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())
.map_err(|error| sentry_error("opening host config", &error))?;
require_regular_file(&config_source)
.map_err(|error| sentry_error("validating host config", &error))?;
let staged_config = File::from(
descriptor_bind(&config_source, &transient_config)
.map_err(|error| sentry_error("staging config mount", &error))?,
);
if let Err(error) = descriptor_remount_read_only(&transient_config_label)
.map_err(|error| sentry_error("making config mount read-only", &error))
{
return Err(cleanup_staged(error, &transient_config_label));
}
let config_target =
match common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty()) {
Ok(config_target) => config_target,
Err(error) => {
return Err(cleanup_staged(
sentry_error("opening config mount target", &error),
&transient_config_label,
));
}
};
if let Err(error) = descriptor_move(&staged_config, &config_target)
.map_err(|error| sentry_error("installing config mount", &error))
{
return Err(cleanup_staged(error, &transient_config_label));
}
let config = InstalledMount {
mounted_target: staged_config,
cleanup_target: transient_config,
cleanup_label: transient_config_label,
};
let hook_source = match transient_hooks.duplicate() {
Ok(hook_source) => hook_source,
Err(error) => {
return Err(cleanup_installed(
sentry_error("retaining transient hooks", &error),
config,
));
}
};
let hook_target = match common.open_entry(
Path::new("hooks"),
OFlags::RDONLY | OFlags::DIRECTORY,
Mode::empty(),
) {
Ok(hook_target) => hook_target,
Err(error) => {
return Err(cleanup_installed(
sentry_error("opening hooks mount target", &error),
config,
));
}
};
let mounted_hooks = match descriptor_bind(&hook_source, &hook_target)
.map_err(|error| sentry_error("installing hooks mount", &error))
{
Ok(mounted_hooks) => File::from(mounted_hooks),
Err(error) => {
return Err(cleanup_installed(error, config));
}
};
Ok(Self {
config,
hooks: InstalledMount {
mounted_target: mounted_hooks,
cleanup_target: hook_source,
cleanup_label: transient_hooks_label,
},
})
}
/// # Errors
/// Returns an error unless both exact protective mountpoints are detached.
pub fn unmount(self) -> io::Result<()> {
let config = self.config.unmount();
let hooks = self.hooks.unmount();
match (config, hooks) {
(Ok(()), Ok(())) => Ok(()),
(config, hooks) => Err(io::Error::other(format!(
"config={config:?}, hooks={hooks:?}"
))),
}
}
}
fn sentry_error(action: &str, error: &io::Error) -> io::Error {
io::Error::new(error.kind(), format!("{action}: {error}"))
}
fn cleanup_staged(error: io::Error, label: &Path) -> io::Error {
combine_cleanup(
error,
unmount(label, UnmountFlags::DETACH).map_err(Into::into),
)
}
fn cleanup_installed(error: io::Error, mount: InstalledMount) -> io::Error {
combine_cleanup(error, mount.unmount())
}
fn combine_cleanup(error: io::Error, cleanup: io::Result<()>) -> io::Error {
match cleanup {
Ok(()) => error,
Err(cleanup) => io::Error::new(error.kind(), format!("{error}; cleanup failed: {cleanup}")),
}
}
fn descriptor_bind(source: &File, target: &File) -> io::Result<std::os::fd::OwnedFd> {
let mount = open_tree(
source,
"",
OpenTreeFlags::OPEN_TREE_CLONE
| OpenTreeFlags::OPEN_TREE_CLOEXEC
| OpenTreeFlags::AT_EMPTY_PATH,
)?;
move_mount(
&mount,
"",
target,
"",
MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH | MoveMountFlags::MOVE_MOUNT_T_EMPTY_PATH,
)
.map_err(io::Error::from)?;
Ok(mount)
}
fn descriptor_move(source: &File, target: &File) -> io::Result<()> {
move_mount(
source,
"",
target,
"",
MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH | MoveMountFlags::MOVE_MOUNT_T_EMPTY_PATH,
)
.map_err(Into::into)
}
fn descriptor_remount_read_only(target: &Path) -> io::Result<()> {
mount_remount(
target,
MountFlags::BIND
| MountFlags::RDONLY
| MountFlags::NOSUID
| MountFlags::NODEV
| MountFlags::NOEXEC,
"",
)
.map_err(Into::into)
}
fn require_empty_regular_file(file: &File) -> io::Result<()> {
let metadata = fstat(file)?;
if !FileType::from_raw_mode(metadata.st_mode).is_file() {
return Err(io::Error::other(
"transient Git config authority is not a regular file",
));
}
if metadata.st_size != 0 {
return Err(io::Error::other(
"transient Git config authority is not an empty regular file",
));
}
Ok(())
}
fn require_regular_file(file: &File) -> io::Result<()> {
if FileType::from_raw_mode(fstat(file)?.st_mode).is_file() {
Ok(())
} else {
Err(io::Error::other("host Git config is not a regular file"))
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::path::PathBuf;
use super::{GitSentryConfiguration, GitSentryLaunch};
fn configuration() -> GitSentryConfiguration {
GitSentryConfiguration {
mounted_workspace: PathBuf::from("/workspace"),
host_git_common_directory: PathBuf::from("/host/.git"),
transient_hooks: PathBuf::from("/transient/hooks"),
transient_config: PathBuf::from("/transient/config"),
}
}
#[test]
fn launch_arguments_round_trip_through_the_binary_parser() {
let launch = GitSentryLaunch::new(configuration());
let parsed = GitSentryLaunch::parse(launch.arguments().into()).unwrap();
assert_eq!(parsed, launch);
assert_eq!(
parsed.configuration().transient_config,
PathBuf::from("/transient/config")
);
}
#[test]
fn launch_parser_requires_every_ordered_authority() {
for count in [0, 1, 2, 3, 5] {
let arguments = (0..count)
.map(|index| OsString::from(format!("/{index}")))
.collect();
assert!(GitSentryLaunch::parse(arguments).is_err());
}
}
}