Give the Git Sentry launch shape one Rust owner, update the physical corpus to supply every authority, and make the default repository gate distinguish unavailable physical prerequisites from corpus failures.
355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|
|
}
|