Implement fail-closed ignored workspace routing

This commit is contained in:
ms2k 2026-07-16 13:03:04 +09:00
commit 95104fa4e7
8 changed files with 6024 additions and 6 deletions

View file

@ -0,0 +1,339 @@
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io;
use std::io::Read;
use std::os::fd::AsFd;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Component, Path};
use rustix::fs::{
AtFlags, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, Stat, Timestamps, linkat, mkdirat,
openat, openat2, readlinkat, renameat_with, statat, symlinkat, unlinkat, utimensat,
};
const RESOLVE: ResolveFlags = ResolveFlags::BENEATH
.union(ResolveFlags::NO_MAGICLINKS)
.union(ResolveFlags::NO_SYMLINKS);
#[derive(Debug)]
pub(crate) struct BackingRoot {
directory: File,
}
impl BackingRoot {
pub(crate) fn open(path: &Path) -> io::Result<Self> {
let directory = File::from(openat2(
rustix::fs::CWD,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
)?);
Ok(Self { directory })
}
pub(crate) fn from_file(directory: File) -> io::Result<Self> {
let stat = rustix::fs::fstat(directory.as_fd())?;
if stat.st_mode & libc::S_IFMT != libc::S_IFDIR {
return Err(io::Error::other("backing authority is not a directory"));
}
Ok(Self { directory })
}
pub(crate) fn identity(&self) -> io::Result<(u64, u64)> {
let stat = rustix::fs::fstat(self.directory.as_fd())?;
Ok((stat.st_dev, stat.st_ino))
}
pub(crate) fn duplicate(&self) -> io::Result<File> {
self.directory.try_clone()
}
pub(crate) fn stat(&self, relative: &Path) -> io::Result<Stat> {
if relative.as_os_str().is_empty() {
return Ok(rustix::fs::fstat(self.directory.as_fd())?);
}
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
Ok(statat(parent.as_fd(), name, AtFlags::SYMLINK_NOFOLLOW)?)
}
pub(crate) fn open_entry(
&self,
relative: &Path,
flags: OFlags,
mode: Mode,
) -> io::Result<File> {
if relative.as_os_str().is_empty() {
return self.duplicate();
}
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
Ok(File::from(openat(
parent.as_fd(),
name,
flags | OFlags::NOFOLLOW | OFlags::CLOEXEC,
mode,
)?))
}
pub(crate) fn open_directory(&self, relative: &Path) -> io::Result<File> {
validate_relative(relative)?;
let path = if relative.as_os_str().is_empty() {
Path::new(".")
} else {
relative
};
Ok(File::from(openat2(
self.directory.as_fd(),
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
RESOLVE,
)?))
}
pub(crate) fn ensure_parent_directories(&self, relative: &Path) -> io::Result<()> {
let parent = relative.parent().unwrap_or_else(|| Path::new(""));
validate_relative(parent)?;
let mut current = self.duplicate()?;
for component in parent.components() {
let Component::Normal(name) = component else {
return Err(invalid_path());
};
match mkdirat(current.as_fd(), name, Mode::from_raw_mode(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
current = File::from(openat(
current.as_fd(),
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
}
Ok(())
}
pub(crate) fn mkdir(&self, relative: &Path, mode: Mode) -> io::Result<()> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
Ok(mkdirat(parent.as_fd(), name, mode)?)
}
pub(crate) fn unlink(&self, relative: &Path, directory: bool) -> io::Result<()> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
let flags = if directory {
AtFlags::REMOVEDIR
} else {
AtFlags::empty()
};
Ok(unlinkat(parent.as_fd(), name, flags)?)
}
pub(crate) fn rename(
&self,
source: &Path,
destination: &Path,
flags: RenameFlags,
) -> io::Result<()> {
let (source_parent, source_name) = split(source)?;
let (destination_parent, destination_name) = split(destination)?;
let source_parent = self.open_directory(source_parent)?;
let destination_parent = self.open_directory(destination_parent)?;
Ok(renameat_with(
source_parent.as_fd(),
source_name,
destination_parent.as_fd(),
destination_name,
flags,
)?)
}
pub(crate) fn link(&self, source: &Path, destination: &Path) -> io::Result<()> {
let (source_parent, source_name) = split(source)?;
let (destination_parent, destination_name) = split(destination)?;
let source_parent = self.open_directory(source_parent)?;
let destination_parent = self.open_directory(destination_parent)?;
Ok(linkat(
source_parent.as_fd(),
source_name,
destination_parent.as_fd(),
destination_name,
AtFlags::empty(),
)?)
}
pub(crate) fn symlink(&self, target: &Path, relative: &Path) -> io::Result<()> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
Ok(symlinkat(target, parent.as_fd(), name)?)
}
pub(crate) fn readlink(&self, relative: &Path) -> io::Result<Vec<u8>> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
Ok(readlinkat(parent.as_fd(), name, Vec::new())?.into_bytes())
}
pub(crate) fn set_symlink_times(
&self,
relative: &Path,
timestamps: &Timestamps,
) -> io::Result<()> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
Ok(utimensat(
parent.as_fd(),
name,
timestamps,
AtFlags::SYMLINK_NOFOLLOW,
)?)
}
pub(crate) fn entries(&self, relative: &Path) -> io::Result<Vec<OsString>> {
Ok(self
.typed_entries(relative)?
.into_iter()
.map(|(name, _)| name)
.collect())
}
pub(crate) fn typed_entries(
&self,
relative: &Path,
) -> io::Result<Vec<(OsString, rustix::fs::FileType)>> {
let directory = self.open_directory(relative)?;
let mut buffer = vec![std::mem::MaybeUninit::<u8>::uninit(); 32 * 1024];
let mut entries = RawDir::new(directory, &mut buffer);
let mut names = Vec::new();
while let Some(entry) = entries.next() {
let entry = entry?;
let bytes = entry.file_name().to_bytes();
if bytes != b"." && bytes != b".." {
names.push((OsString::from_vec(bytes.to_vec()), entry.file_type()));
}
}
Ok(names)
}
pub(crate) fn read_file(&self, relative: &Path, maximum: usize) -> io::Result<Vec<u8>> {
let mut file =
self.open_entry(relative, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty())?;
let metadata = file.metadata()?;
let length = usize::try_from(metadata.len())
.map_err(|_| io::Error::other("file length is out of range"))?;
if !metadata.file_type().is_file() || length > maximum {
return Err(io::Error::other(
"classification input is not a bounded regular file",
));
}
let mut bytes = Vec::with_capacity(length.saturating_add(1));
Read::by_ref(&mut file)
.take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() != length {
return Err(io::Error::other(
"classification input changed while being read",
));
}
Ok(bytes)
}
}
pub(crate) fn validate_relative(path: &Path) -> io::Result<()> {
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
|| path.as_os_str().as_bytes().contains(&0)
{
Err(invalid_path())
} else {
Ok(())
}
}
pub(crate) fn validate_name(name: &OsStr) -> io::Result<()> {
let bytes = name.as_bytes();
if bytes.is_empty()
|| bytes == b"."
|| bytes == b".."
|| bytes.contains(&0)
|| bytes.contains(&b'/')
{
Err(invalid_path())
} else {
Ok(())
}
}
fn split(relative: &Path) -> io::Result<(&Path, &OsStr)> {
validate_relative(relative)?;
let name = relative.file_name().ok_or_else(invalid_path)?;
validate_name(name)?;
Ok((relative.parent().unwrap_or_else(|| Path::new("")), name))
}
fn invalid_path() -> io::Error {
io::Error::from_raw_os_error(libc::EINVAL)
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::symlink;
use std::sync::Arc;
use rustix::fs::{RenameFlags, renameat_with};
use super::BackingRoot;
#[test]
fn descriptor_traversal_race_never_follows_backing_symlinks() {
let base = std::env::current_dir()
.unwrap()
.join("target/ignored-workspace-tests")
.join(format!("confinement-{}", std::process::id()));
let root = base.join("root");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("sentinel"), b"outside").unwrap();
let raced = root.join("raced");
let candidate = root.join("candidate");
std::fs::create_dir(&raced).unwrap();
std::fs::write(raced.join("sentinel"), b"inside").unwrap();
symlink(&outside, &candidate).unwrap();
let authority = Arc::new(BackingRoot::open(&root).unwrap());
let exchange_thread = std::thread::spawn(move || {
for _ in 0..128 {
renameat_with(
rustix::fs::CWD,
&raced,
rustix::fs::CWD,
&candidate,
RenameFlags::EXCHANGE,
)
.unwrap();
renameat_with(
rustix::fs::CWD,
&raced,
rustix::fs::CWD,
&candidate,
RenameFlags::EXCHANGE,
)
.unwrap();
}
});
for _ in 0..256 {
if let Ok(bytes) = authority.read_file(std::path::Path::new("raced/sentinel"), 64) {
assert_eq!(bytes, b"inside");
}
}
exchange_thread.join().unwrap();
assert_eq!(std::fs::read(outside.join("sentinel")).unwrap(), b"outside");
std::fs::remove_dir_all(base).unwrap();
}
}

View file

@ -0,0 +1,30 @@
use std::env;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use lazaret_ignored_workspace::{GitSentry, GitSentryConfiguration};
fn main() {
if let Err(error) = run() {
eprintln!("lazaret Git Sentry refused: {error}");
std::process::exit(1);
}
}
fn run() -> io::Result<()> {
let arguments = env::args_os().skip(1).collect::<Vec<_>>();
if arguments.len() != 3 {
return Err(io::Error::other(
"usage: lazaret-git-sentry MOUNTED_WORKSPACE HOST_GIT_COMMON_DIRECTORY TRANSIENT_HOOKS",
));
}
let sentry = GitSentry::install(&GitSentryConfiguration {
mounted_workspace: PathBuf::from(&arguments[0]),
host_git_common_directory: PathBuf::from(&arguments[1]),
transient_hooks: PathBuf::from(&arguments[2]),
})?;
println!("ready");
io::stdout().flush()?;
io::stdin().read_to_end(&mut Vec::new())?;
sentry.unmount()
}

View file

@ -0,0 +1,168 @@
use std::env;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Read};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf};
use rustix::fs::{AtFlags, StatxFlags, statx};
use sha2::{Digest, Sha256};
const FIXTURE_CONTEXT: &[u8] = b"lazaret-ignored-workspace-fixture-v1\0";
fn main() {
if let Err(error) = run() {
eprintln!("lazaret workspace benchmark refused: {error}");
std::process::exit(1);
}
}
fn run() -> io::Result<()> {
let arguments = env::args_os().skip(1).collect::<Vec<_>>();
let root = parse_metadata_arguments(&arguments)?;
let result = measure_metadata(&root)?;
println!(
"{}",
serde_json::json!({
"content_bytes": result.content_bytes,
"fixture_identity": hex(&result.fixture_identity),
"regular_files": result.regular_files,
})
);
Ok(())
}
fn parse_metadata_arguments(arguments: &[OsString]) -> io::Result<PathBuf> {
if arguments.len() != 6
|| arguments[0] != "metadata"
|| arguments[1] != "--root"
|| arguments[3] != "--bytewise-order"
|| arguments[4] != "--statx-mask"
|| arguments[5] != "basic-stats"
{
return Err(io::Error::other(
"usage: lazaret-workspace-benchmark metadata --root ROOT --bytewise-order --statx-mask basic-stats",
));
}
Ok(PathBuf::from(&arguments[2]))
}
fn measure_metadata(root: &Path) -> io::Result<MetadataResult> {
let mut entries = Vec::new();
collect_entries(root, Path::new(""), &mut entries)?;
entries.sort_by(|left, right| {
left.relative
.as_os_str()
.as_bytes()
.cmp(right.relative.as_os_str().as_bytes())
});
let mut stream = Vec::from(FIXTURE_CONTEXT);
stream.extend_from_slice(
&u32::try_from(entries.len())
.map_err(|_| io::Error::other("fixture contains too many entries"))?
.to_be_bytes(),
);
let mut regular_files = 0_u64;
let mut content_bytes = 0_u64;
for entry in entries {
let path_bytes = entry.relative.as_os_str().as_bytes();
stream.extend_from_slice(
&u32::try_from(path_bytes.len())
.map_err(|_| io::Error::other("fixture path is too long"))?
.to_be_bytes(),
);
stream.extend_from_slice(path_bytes);
stream.push(entry.kind);
stream.extend_from_slice(&(entry.metadata.mode() & 0o7777).to_be_bytes());
let payload_length = u64::try_from(entry.payload.len())
.map_err(|_| io::Error::other("fixture payload is too large"))?;
stream.extend_from_slice(&payload_length.to_be_bytes());
stream.extend_from_slice(&Sha256::digest(&entry.payload));
if entry.kind == 2 {
regular_files = regular_files.saturating_add(1);
content_bytes = content_bytes.saturating_add(payload_length);
}
}
Ok(MetadataResult {
regular_files,
content_bytes,
fixture_identity: Sha256::digest(stream).into(),
})
}
fn collect_entries(
root: &Path,
relative: &Path,
entries: &mut Vec<FixtureEntry>,
) -> io::Result<()> {
let mut children = fs::read_dir(root.join(relative))?
.map(|entry| entry.map(|entry| entry.file_name()))
.collect::<io::Result<Vec<_>>>()?;
children.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
for name in children {
let child_relative = relative.join(name);
let path = root.join(&child_relative);
let metadata = fs::symlink_metadata(&path)?;
statx(
rustix::fs::CWD,
&path,
AtFlags::SYMLINK_NOFOLLOW,
StatxFlags::BASIC_STATS,
)?;
let file_type = metadata.file_type();
let (kind, payload) = if file_type.is_dir() {
(1, Vec::new())
} else if file_type.is_file() {
let mut payload = Vec::new();
fs::File::open(&path)?.read_to_end(&mut payload)?;
(2, payload)
} else if file_type.is_symlink() {
(3, fs::read_link(&path)?.into_os_string().into_vec())
} else if file_type.is_socket()
|| file_type.is_fifo()
|| file_type.is_block_device()
|| file_type.is_char_device()
{
return Err(io::Error::other(
"fixture contains an unsupported entry kind",
));
} else {
return Err(io::Error::other("fixture entry kind is unknown"));
};
entries.push(FixtureEntry {
relative: child_relative.clone(),
metadata,
kind,
payload,
});
if kind == 1 {
collect_entries(root, &child_relative, entries)?;
}
}
Ok(())
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
encoded
}
struct FixtureEntry {
relative: PathBuf,
metadata: fs::Metadata,
kind: u8,
payload: Vec<u8>,
}
struct MetadataResult {
regular_files: u64,
content_bytes: u64,
fixture_identity: [u8; 32],
}

View file

@ -0,0 +1,959 @@
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::{OsStr, OsString};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use bstr::BStr;
use crate::backing::{BackingRoot, validate_relative};
const MAXIMUM_POLICY_BYTES: usize = 16 * 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Classification {
Host,
Isolated,
}
#[derive(Clone)]
pub struct ClassificationSnapshot {
inner: Arc<SnapshotInner>,
}
impl ClassificationSnapshot {
#[must_use]
pub fn classify(&self, relative: &Path, is_directory: bool) -> Classification {
self.inner.classify(relative, is_directory)
}
#[must_use]
pub fn generation(&self) -> u64 {
self.inner.generation
}
pub(crate) fn is_tracked(&self, relative: &Path) -> bool {
self.inner.is_tracked(relative)
}
}
pub struct GitClassifier {
workspace: PathBuf,
root: BackingRoot,
global_excludes: Option<PathBuf>,
current: Mutex<Arc<SnapshotInner>>,
}
impl GitClassifier {
/// # Errors
/// Refuses a non-repository workspace or unreadable Git policy input.
pub fn open(workspace: &Path, global_excludes: Option<PathBuf>) -> io::Result<Self> {
let workspace = workspace.canonicalize()?;
let root = BackingRoot::open(&workspace)?;
Self::from_root(workspace, root, global_excludes)
}
pub(crate) fn from_root(
workspace: PathBuf,
root: BackingRoot,
global_excludes: Option<PathBuf>,
) -> io::Result<Self> {
let current = Arc::new(SnapshotInner::load(
&workspace,
&root,
global_excludes.as_deref(),
1,
)?);
Ok(Self {
workspace,
root,
global_excludes,
current: Mutex::new(current),
})
}
#[must_use]
pub fn snapshot(&self) -> ClassificationSnapshot {
self.snapshot_for(Path::new(""))
}
#[must_use]
pub fn snapshot_for(&self, relative: &Path) -> ClassificationSnapshot {
self.snapshot_for_paths(&[relative])
}
#[must_use]
pub(crate) fn snapshot_for_paths(&self, relatives: &[&Path]) -> ClassificationSnapshot {
let mut current = self
.current
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if relatives
.iter()
.any(|relative| current.relevant_input_changed(&self.root, relative))
{
let generation = current.generation.saturating_add(1);
if let Ok(next) = SnapshotInner::load(
&self.workspace,
&self.root,
self.global_excludes.as_deref(),
generation,
) {
*current = Arc::new(next);
}
}
ClassificationSnapshot {
inner: Arc::clone(&current),
}
}
}
struct SnapshotInner {
generation: u64,
repositories: Vec<RepositoryPolicy>,
workspace_inputs: BTreeMap<PathBuf, Option<InputStamp>>,
external_inputs: BTreeMap<PathBuf, Option<InputStamp>>,
}
impl SnapshotInner {
fn load(
workspace: &Path,
root: &BackingRoot,
global_excludes: Option<&Path>,
generation: u64,
) -> io::Result<Self> {
let mut repository_roots = vec![PathBuf::new()];
let mut ignore_files = Vec::new();
let mut workspace_inputs = BTreeMap::new();
scan_workspace(
root,
Path::new(""),
&mut Vec::new(),
&mut repository_roots,
&mut ignore_files,
&mut workspace_inputs,
)?;
repository_roots.sort_by_key(|path| path.components().count());
repository_roots.dedup();
let all_repository_roots = repository_roots.clone();
let mut external_inputs = BTreeMap::new();
let mut repositories = Vec::new();
for prefix in repository_roots {
let metadata = repository_metadata(workspace, root, &prefix)?;
for path in [&metadata.index, &metadata.config, &metadata.info_exclude] {
external_inputs.insert(path.clone(), input_stamp(path)?);
}
let applicable_ignores = ignore_files
.iter()
.filter(|path| {
all_repository_roots
.iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
== Some(&prefix)
})
.cloned()
.collect::<Vec<_>>();
repositories.push(RepositoryPolicy::load(
workspace,
root,
prefix,
&metadata,
applicable_ignores,
global_excludes,
)?);
}
if let Some(path) = global_excludes {
external_inputs.insert(path.to_path_buf(), input_stamp(path)?);
}
for (path, expected) in &workspace_inputs {
if workspace_input_stamp(root, path)? != *expected {
return Err(io::Error::other(
"classification input changed while building a snapshot",
));
}
}
for (path, expected) in &external_inputs {
if input_stamp(path)? != *expected {
return Err(io::Error::other(
"classification input changed while building a snapshot",
));
}
}
Ok(Self {
generation,
repositories,
workspace_inputs,
external_inputs,
})
}
fn classify(&self, relative: &Path, is_directory: bool) -> Classification {
if relative.as_os_str().is_empty()
|| relative
.components()
.next()
.is_some_and(|component| component.as_os_str() == OsStr::new(".git"))
{
return Classification::Host;
}
let repository = self
.repositories
.iter()
.filter(|repository| relative.starts_with(&repository.prefix))
.max_by_key(|repository| repository.prefix.components().count());
repository.map_or(Classification::Host, |repository| {
repository.classify(relative, is_directory)
})
}
fn is_tracked(&self, relative: &Path) -> bool {
self.repositories
.iter()
.filter(|repository| relative.starts_with(&repository.prefix))
.max_by_key(|repository| repository.prefix.components().count())
.is_some_and(|repository| repository.is_tracked(relative))
}
fn relevant_input_changed(&self, root: &BackingRoot, relative: &Path) -> bool {
if validate_relative(relative).is_err() {
return false;
}
let mut directory = if root
.stat(relative)
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR)
{
relative
} else {
relative.parent().unwrap_or_else(|| Path::new(""))
};
loop {
for name in [OsStr::new(".gitignore"), OsStr::new(".git")] {
let path = directory.join(name);
let Ok(current) = workspace_input_stamp(root, &path) else {
return true;
};
if self.workspace_inputs.get(&path).copied().unwrap_or(None) != current {
return true;
}
}
let Some(parent) = directory.parent() else {
break;
};
if parent == directory {
break;
}
directory = parent;
}
self.external_inputs
.iter()
.any(|(path, expected)| match input_stamp(path) {
Ok(current) => current != *expected,
Err(_) => true,
})
}
}
struct RepositoryPolicy {
prefix: PathBuf,
tracked: BTreeSet<Vec<u8>>,
tracked_directories: BTreeSet<Vec<u8>>,
search: gix_ignore::Search,
case: gix_ignore::glob::pattern::Case,
}
impl RepositoryPolicy {
fn load(
workspace: &Path,
root: &BackingRoot,
prefix: PathBuf,
metadata: &RepositoryMetadata,
mut ignore_files: Vec<PathBuf>,
global_excludes: Option<&Path>,
) -> io::Result<Self> {
let repository = workspace.join(&prefix);
let tracked = match read_external_optional(&metadata.index)? {
Some(index) => parse_index_paths(&index)?,
None => BTreeSet::new(),
};
let mut tracked_directories = BTreeSet::new();
for path in &tracked {
for separator in path
.iter()
.enumerate()
.filter_map(|(index, byte)| (*byte == b'/').then_some(index))
{
tracked_directories.insert(path[..separator].to_vec());
}
}
let mut search = gix_ignore::Search::default();
if let Some(global) = global_excludes
&& let Some(bytes) = read_external_optional(global)?
{
search.add_patterns_buffer(
&bytes,
global,
Some(&repository),
gix_ignore::search::Ignore::default(),
);
}
if let Some(bytes) = read_external_optional(&metadata.info_exclude)? {
search.add_patterns_buffer(
&bytes,
&metadata.info_exclude,
None,
gix_ignore::search::Ignore::default(),
);
}
ignore_files.sort_by_key(|path| path.components().count());
for path in ignore_files {
let bytes = root.read_file(&path, MAXIMUM_POLICY_BYTES)?;
let source = workspace.join(&path);
search.add_patterns_buffer(
&bytes,
source,
Some(&repository),
gix_ignore::search::Ignore::default(),
);
}
let ignore_case = read_external_optional(&metadata.config)?
.is_some_and(|bytes| config_boolean(&bytes, b"core", b"ignorecase"));
let case = if ignore_case {
gix_ignore::glob::pattern::Case::Fold
} else {
gix_ignore::glob::pattern::Case::Sensitive
};
Ok(Self {
prefix,
tracked,
tracked_directories,
search,
case,
})
}
fn classify(&self, relative: &Path, is_directory: bool) -> Classification {
let repository_relative = relative
.strip_prefix(&self.prefix)
.unwrap_or(relative)
.as_os_str()
.as_bytes();
if self.tracked.contains(repository_relative)
|| self.tracked_directories.contains(repository_relative)
{
return Classification::Host;
}
let ignored_ancestor = repository_relative
.iter()
.enumerate()
.filter_map(|(index, byte)| (*byte == b'/').then_some(&repository_relative[..index]))
.any(|ancestor| {
self.search
.pattern_matching_relative_path(BStr::new(ancestor), Some(true), self.case)
.is_some_and(|matched| !matched.pattern.is_negative())
});
let ignored = ignored_ancestor
|| self
.search
.pattern_matching_relative_path(
BStr::new(repository_relative),
Some(is_directory),
self.case,
)
.is_some_and(|matched| !matched.pattern.is_negative());
if ignored {
Classification::Isolated
} else {
Classification::Host
}
}
fn is_tracked(&self, relative: &Path) -> bool {
let repository_relative = relative
.strip_prefix(&self.prefix)
.unwrap_or(relative)
.as_os_str()
.as_bytes();
self.tracked.contains(repository_relative)
}
}
struct RepositoryMetadata {
index: PathBuf,
config: PathBuf,
info_exclude: PathBuf,
}
fn repository_metadata(
workspace: &Path,
root: &BackingRoot,
prefix: &Path,
) -> io::Result<RepositoryMetadata> {
let dot_git = prefix.join(".git");
let stat = root.stat(&dot_git)?;
let repository = workspace.join(prefix);
let git_dir = if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
workspace.join(&dot_git)
} else if stat.st_mode & libc::S_IFMT == libc::S_IFREG {
let bytes = root.read_file(&dot_git, 64 * 1024)?;
let value = bytes
.strip_prefix(b"gitdir:")
.ok_or_else(|| io::Error::other("worktree Git pointer has no gitdir prefix"))?;
let value = trim_ascii(value);
let path = PathBuf::from(OsString::from_vec(value.to_vec()));
if path.is_absolute() {
path
} else {
repository.join(path)
}
} else {
return Err(io::Error::other(
"repository .git entry has unsupported type",
));
};
let git_root = BackingRoot::open(&git_dir)?;
let common_dir = match git_root.read_file(Path::new("commondir"), 64 * 1024) {
Ok(bytes) => {
let path = PathBuf::from(OsString::from_vec(trim_ascii(&bytes).to_vec()));
if path.is_absolute() {
path
} else {
git_dir.join(path)
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => git_dir.clone(),
Err(error) => return Err(error),
};
Ok(RepositoryMetadata {
index: git_dir.join("index"),
config: common_dir.join("config"),
info_exclude: common_dir.join("info/exclude"),
})
}
fn scan_workspace(
root: &BackingRoot,
directory: &Path,
patterns: &mut Vec<(PathBuf, Vec<u8>)>,
repositories: &mut Vec<PathBuf>,
ignore_files: &mut Vec<PathBuf>,
inputs: &mut BTreeMap<PathBuf, Option<InputStamp>>,
) -> io::Result<()> {
let local_ignore = directory.join(".gitignore");
let has_local_ignore = match root.read_file(&local_ignore, MAXIMUM_POLICY_BYTES) {
Ok(bytes) => {
let stat = root.stat(&local_ignore)?;
inputs.insert(local_ignore.clone(), Some(InputStamp::from_stat(stat)));
ignore_files.push(local_ignore.clone());
patterns.push((local_ignore.clone(), bytes));
true
}
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) if error.raw_os_error() == Some(libc::EISDIR) => false,
Err(error) => return Err(error),
};
for (name, entry_type) in root.typed_entries(directory)? {
let path = directory.join(&name);
if name == OsStr::new(".gitignore") {
continue;
}
if name == OsStr::new(".git") {
let stat = root.stat(&path)?;
inputs.insert(path, Some(InputStamp::from_stat(stat)));
if !directory.as_os_str().is_empty() {
repositories.push(directory.to_path_buf());
}
continue;
}
let directory_entry = entry_type == rustix::fs::FileType::Directory
|| (entry_type == rustix::fs::FileType::Unknown
&& root
.stat(&path)
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR));
if directory_entry && !patterns_ignore(patterns, &path) {
scan_workspace(root, &path, patterns, repositories, ignore_files, inputs)?;
}
}
let missing_ignore = directory.join(".gitignore");
inputs.entry(missing_ignore).or_insert(None);
let missing_git = directory.join(".git");
inputs.entry(missing_git).or_insert(None);
if has_local_ignore {
patterns.pop();
}
Ok(())
}
fn patterns_ignore(patterns: &[(PathBuf, Vec<u8>)], path: &Path) -> bool {
let mut search = gix_ignore::Search::default();
for (source, bytes) in patterns {
search.add_patterns_buffer(
bytes,
source,
Some(Path::new("")),
gix_ignore::search::Ignore::default(),
);
}
search
.pattern_matching_relative_path(
BStr::new(path.as_os_str().as_bytes()),
Some(true),
gix_ignore::glob::pattern::Case::Sensitive,
)
.is_some_and(|matched| !matched.pattern.is_negative())
}
fn read_external_optional(path: &Path) -> io::Result<Option<Vec<u8>>> {
let Some(parent) = path.parent() else {
return Err(io::Error::other("classification input has no parent"));
};
let Some(name) = path.file_name() else {
return Err(io::Error::other("classification input has no name"));
};
let root = match BackingRoot::open(parent) {
Ok(root) => root,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
match root.read_file(Path::new(name), MAXIMUM_POLICY_BYTES) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn parse_index_paths(bytes: &[u8]) -> io::Result<BTreeSet<Vec<u8>>> {
match (
parse_index_paths_with_oid(bytes, 20),
parse_index_paths_with_oid(bytes, 32),
) {
(Ok(paths), Err(_)) | (Err(_), Ok(paths)) => Ok(paths),
(Ok(first), Ok(second)) if first == second => Ok(first),
(Ok(_), Ok(_)) => Err(io::Error::other("Git index object format is ambiguous")),
(Err(first), Err(_)) => Err(first),
}
}
fn parse_index_paths_with_oid(bytes: &[u8], oid_bytes: usize) -> io::Result<BTreeSet<Vec<u8>>> {
if bytes.len() < 12 || &bytes[..4] != b"DIRC" {
return Err(io::Error::other("Git index header is malformed"));
}
let version = read_u32(bytes, 4)?;
if !(2..=4).contains(&version) {
return Err(io::Error::other("Git index version is unsupported"));
}
let count = usize::try_from(read_u32(bytes, 8)?)
.map_err(|_| io::Error::other("Git index entry count is out of range"))?;
let mut offset = 12usize;
let mut previous = Vec::new();
let mut paths = BTreeSet::new();
for _ in 0..count {
let entry_start = offset;
let flags_offset = offset
.checked_add(40)
.and_then(|offset| offset.checked_add(oid_bytes))
.ok_or_else(index_overflow)?;
let flags = read_u16(bytes, flags_offset)?;
offset = flags_offset.checked_add(2).ok_or_else(index_overflow)?;
if flags & 0x4000 != 0 {
offset = offset.checked_add(2).ok_or_else(index_overflow)?;
}
let path = if version == 4 {
let (removed, next) = read_index_varint(bytes, offset)?;
offset = next;
if removed > previous.len() {
return Err(io::Error::other("Git index v4 prefix is malformed"));
}
let end = find_nul(bytes, offset)?;
let mut path = previous[..previous.len() - removed].to_vec();
path.extend_from_slice(&bytes[offset..end]);
offset = end.checked_add(1).ok_or_else(index_overflow)?;
path
} else {
let end = find_nul(bytes, offset)?;
let path = bytes[offset..end].to_vec();
offset = end.checked_add(1).ok_or_else(index_overflow)?;
let length = offset.checked_sub(entry_start).ok_or_else(index_overflow)?;
let padding = (8 - length % 8) % 8;
offset = offset.checked_add(padding).ok_or_else(index_overflow)?;
path
};
if path.is_empty() || path.contains(&0) || !paths.insert(path.clone()) {
return Err(io::Error::other("Git index path is empty or duplicated"));
}
previous = path;
}
validate_index_tail(bytes, offset, oid_bytes)?;
Ok(paths)
}
pub(crate) fn tracked_paths_from_index(bytes: &[u8]) -> io::Result<BTreeSet<Vec<u8>>> {
parse_index_paths(bytes)
}
fn validate_index_tail(bytes: &[u8], mut offset: usize, oid_bytes: usize) -> io::Result<()> {
let checksum_start = bytes
.len()
.checked_sub(oid_bytes)
.ok_or_else(|| io::Error::other("Git index checksum is absent"))?;
while offset < checksum_start {
let signature_end = offset.checked_add(4).ok_or_else(index_overflow)?;
let signature = bytes
.get(offset..signature_end)
.ok_or_else(|| io::Error::other("Git index extension is truncated"))?;
if !signature.iter().all(u8::is_ascii_uppercase) {
return Err(io::Error::other(
"Git index extension signature is malformed",
));
}
let length = usize::try_from(read_u32(bytes, signature_end)?)
.map_err(|_| io::Error::other("Git index extension size is out of range"))?;
offset = signature_end
.checked_add(4)
.and_then(|offset| offset.checked_add(length))
.ok_or_else(index_overflow)?;
if offset > checksum_start {
return Err(io::Error::other(
"Git index extension exceeds the checksum boundary",
));
}
}
if offset == checksum_start {
Ok(())
} else {
Err(io::Error::other("Git index tail is malformed"))
}
}
fn read_index_varint(bytes: &[u8], mut offset: usize) -> io::Result<(usize, usize)> {
let mut value = 0usize;
loop {
let byte = *bytes
.get(offset)
.ok_or_else(|| io::Error::other("Git index v4 varint is truncated"))?;
offset = offset.checked_add(1).ok_or_else(index_overflow)?;
value = value
.checked_shl(7)
.and_then(|value| value.checked_add(usize::from(byte & 0x7f)))
.ok_or_else(index_overflow)?;
if byte & 0x80 == 0 {
return Ok((value, offset));
}
value = value.checked_add(1).ok_or_else(index_overflow)?;
}
}
fn read_u32(bytes: &[u8], offset: usize) -> io::Result<u32> {
let end = offset.checked_add(4).ok_or_else(index_overflow)?;
let array: [u8; 4] = bytes
.get(offset..end)
.ok_or_else(|| io::Error::other("Git index is truncated"))?
.try_into()
.map_err(|_| io::Error::other("Git index field is malformed"))?;
Ok(u32::from_be_bytes(array))
}
fn read_u16(bytes: &[u8], offset: usize) -> io::Result<u16> {
let end = offset.checked_add(2).ok_or_else(index_overflow)?;
let array: [u8; 2] = bytes
.get(offset..end)
.ok_or_else(|| io::Error::other("Git index is truncated"))?
.try_into()
.map_err(|_| io::Error::other("Git index field is malformed"))?;
Ok(u16::from_be_bytes(array))
}
fn find_nul(bytes: &[u8], offset: usize) -> io::Result<usize> {
bytes
.get(offset..)
.and_then(|tail| tail.iter().position(|byte| *byte == 0))
.and_then(|position| offset.checked_add(position))
.ok_or_else(|| io::Error::other("Git index pathname is unterminated"))
}
fn index_overflow() -> io::Error {
io::Error::other("Git index offset is out of range")
}
fn config_boolean(bytes: &[u8], section: &[u8], key: &[u8]) -> bool {
let mut current = Vec::new();
for line in bytes.split(|byte| *byte == b'\n') {
let line = trim_ascii(line);
if line.starts_with(b"[") && line.ends_with(b"]") {
current = line[1..line.len() - 1]
.split(u8::is_ascii_whitespace)
.next()
.unwrap_or_default()
.to_ascii_lowercase();
} else if current == section {
let mut fields = line.splitn(2, |byte| *byte == b'=');
let name = trim_ascii(fields.next().unwrap_or_default()).to_ascii_lowercase();
let value = trim_ascii(fields.next().unwrap_or(b"true"));
if name == key {
return value.eq_ignore_ascii_case(b"true")
|| value.eq_ignore_ascii_case(b"yes")
|| value == b"1";
}
}
}
false
}
fn trim_ascii(mut bytes: &[u8]) -> &[u8] {
while bytes.first().is_some_and(u8::is_ascii_whitespace) {
bytes = &bytes[1..];
}
while bytes.last().is_some_and(u8::is_ascii_whitespace) {
bytes = &bytes[..bytes.len() - 1];
}
bytes
}
fn input_stamp(path: &Path) -> io::Result<Option<InputStamp>> {
let parent = path
.parent()
.ok_or_else(|| io::Error::other("classification input has no parent"))?;
let name = path
.file_name()
.ok_or_else(|| io::Error::other("classification input has no name"))?;
let root = match BackingRoot::open(parent) {
Ok(root) => root,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
match root.stat(Path::new(name)) {
Ok(stat) => Ok(Some(InputStamp::from_stat(stat))),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn workspace_input_stamp(root: &BackingRoot, path: &Path) -> io::Result<Option<InputStamp>> {
match root.stat(path) {
Ok(stat) => Ok(Some(InputStamp::from_stat(stat))),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct InputStamp {
device: u64,
inode: u64,
size: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
changed_seconds: i64,
changed_nanoseconds: i64,
}
impl InputStamp {
fn from_stat(stat: rustix::fs::Stat) -> Self {
Self {
device: stat.st_dev,
inode: stat.st_ino,
size: u64::try_from(stat.st_size).unwrap_or(0),
modified_seconds: stat.st_mtime,
modified_nanoseconds: i64::try_from(stat.st_mtime_nsec).unwrap_or(i64::MAX),
changed_seconds: stat.st_ctime,
changed_nanoseconds: i64::try_from(stat.st_ctime_nsec).unwrap_or(i64::MAX),
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use super::{Classification, GitClassifier, parse_index_paths};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[test]
fn index_v2_paths_are_parsed_without_utf8_assumptions() {
let bytes = index_v2(&[b"src/lib.rs", b"target/\xffapp"]);
let paths = parse_index_paths(&bytes).unwrap();
assert!(paths.contains(b"src/lib.rs".as_slice()));
assert!(paths.contains(b"target/\xffapp".as_slice()));
}
#[test]
fn malformed_index_is_refused() {
assert!(parse_index_paths(b"DIRC\0\0\0\x05\0\0\0\0").is_err());
let mut truncated = index_v2(&[b"tracked"]);
truncated.truncate(40);
assert!(parse_index_paths(&truncated).is_err());
}
#[test]
fn repository_without_an_index_has_no_tracked_paths() {
let fixture = Fixture::new("absent-index");
repository(&fixture.workspace, &[]);
fs::remove_file(fixture.workspace.join(".git/index")).unwrap();
fs::write(fixture.workspace.join(".gitignore"), b"target/\n").unwrap();
let classifier = GitClassifier::open(&fixture.workspace, None).unwrap();
assert_eq!(
classifier
.snapshot_for(std::path::Path::new("target/output"))
.classify(std::path::Path::new("target/output"), false),
Classification::Isolated
);
}
#[test]
fn info_exclude_is_root_scoped_with_source_provenance() {
let fixture = Fixture::new("info-exclude");
repository(&fixture.workspace, &[]);
let source = fixture.workspace.join(".git/info/exclude");
fs::write(&source, b"local.secret\n").unwrap();
let classifier = GitClassifier::open(&fixture.workspace, None).unwrap();
let snapshot = classifier.snapshot_for(std::path::Path::new("local.secret"));
assert_eq!(
snapshot.classify(std::path::Path::new("local.secret"), false),
Classification::Isolated
);
assert!(
snapshot.inner.repositories[0]
.search
.patterns
.iter()
.any(
|patterns| patterns.source.as_deref() == Some(source.as_path())
&& patterns.base.is_none()
)
);
}
#[test]
fn linked_worktree_git_directory_refuses_symlink_traversal() {
let fixture = Fixture::new("linked-symlink");
let actual = fixture.root.join("actual-git");
fs::create_dir_all(actual.join("info")).unwrap();
fs::write(actual.join("config"), b"[core]\nignoreCase = false\n").unwrap();
fs::write(actual.join("index"), index_v2(&[])).unwrap();
let alias = fixture.root.join("git-alias");
std::os::unix::fs::symlink(&actual, &alias).unwrap();
fs::write(
fixture.workspace.join(".git"),
format!("gitdir: {}\n", alias.display()),
)
.unwrap();
assert!(GitClassifier::open(&fixture.workspace, None).is_err());
}
#[test]
fn snapshots_route_tracked_ordinary_ignored_and_live_policy_changes() {
let fixture = Fixture::new("classification");
repository(&fixture.workspace, &[b"tracked.cache"]);
fs::write(fixture.workspace.join(".gitignore"), b"*.cache\n").unwrap();
fs::write(fixture.workspace.join("tracked.cache"), b"tracked").unwrap();
let classifier = GitClassifier::open(&fixture.workspace, None).unwrap();
let first = classifier.snapshot_for(std::path::Path::new("ignored.cache"));
assert_eq!(
first.classify(std::path::Path::new("tracked.cache"), false),
Classification::Host
);
assert_eq!(
first.classify(std::path::Path::new("ignored.cache"), false),
Classification::Isolated
);
assert_eq!(
first.classify(std::path::Path::new("ordinary"), false),
Classification::Host
);
fs::write(fixture.workspace.join(".gitignore"), b"*.different\n").unwrap();
let second = classifier.snapshot_for(std::path::Path::new("ignored.cache"));
assert!(second.generation() > first.generation());
assert_eq!(
second.classify(std::path::Path::new("ignored.cache"), false),
Classification::Host
);
}
#[test]
fn core_ignore_case_and_nested_repository_policy_are_selected() {
let fixture = Fixture::new("nested");
repository(&fixture.workspace, &[]);
fs::write(
fixture.workspace.join(".git/config"),
b"[core]\nignoreCase = true\n",
)
.unwrap();
fs::write(fixture.workspace.join(".gitignore"), b"build.log\n").unwrap();
let nested = fixture.workspace.join("vendor/nested");
fs::create_dir_all(&nested).unwrap();
repository(&nested, &[]);
fs::write(nested.join(".gitignore"), b"target/\n").unwrap();
let classifier = GitClassifier::open(&fixture.workspace, None).unwrap();
let snapshot = classifier.snapshot_for(std::path::Path::new("BUILD.LOG"));
assert_eq!(
snapshot.classify(std::path::Path::new("BUILD.LOG"), false),
Classification::Isolated
);
assert_eq!(
snapshot.classify(std::path::Path::new("vendor/nested/target/app"), false),
Classification::Isolated
);
}
fn repository(root: &std::path::Path, tracked: &[&[u8]]) {
fs::create_dir_all(root.join(".git/info")).unwrap();
fs::create_dir_all(root.join(".git/hooks")).unwrap();
fs::write(root.join(".git/config"), b"[core]\nignoreCase = false\n").unwrap();
fs::write(root.join(".git/info/exclude"), b"").unwrap();
fs::write(root.join(".git/index"), index_v2(tracked)).unwrap();
}
fn index_v2(paths: &[&[u8]]) -> Vec<u8> {
let mut bytes = b"DIRC".to_vec();
bytes.extend_from_slice(&2u32.to_be_bytes());
bytes.extend_from_slice(&u32::try_from(paths.len()).unwrap().to_be_bytes());
for path in paths {
let start = bytes.len();
bytes.extend_from_slice(&[0; 60]);
bytes.extend_from_slice(&u16::try_from(path.len().min(0x0fff)).unwrap().to_be_bytes());
bytes.extend_from_slice(path);
bytes.push(0);
while !(bytes.len() - start).is_multiple_of(8) {
bytes.push(0);
}
}
bytes.extend_from_slice(&[0; 20]);
bytes
}
struct Fixture {
root: std::path::PathBuf,
workspace: std::path::PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let root = std::env::current_dir()
.unwrap()
.join("target/ignored-workspace-tests")
.join(format!("{name}-{}-{sequence}", std::process::id()));
let workspace = root.join("workspace");
fs::create_dir_all(&workspace).unwrap();
Self { root, workspace }
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.root).unwrap();
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
mod backing;
mod classifier;
mod filesystem;
mod sentry;
pub use classifier::{Classification, ClassificationSnapshot, GitClassifier};
pub use filesystem::{IgnoredWorkspace, MountConfiguration};
pub use sentry::{GitSentry, GitSentryConfiguration};

View file

@ -0,0 +1,57 @@
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
use lazaret_ignored_workspace::{IgnoredWorkspace, MountConfiguration};
fn main() {
if let Err(error) = run() {
eprintln!("lazaret ignored workspace refused: {error}");
std::process::exit(1);
}
}
fn run() -> io::Result<()> {
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
let configuration = parse(&arguments)?;
IgnoredWorkspace::mount(configuration)
}
fn parse(arguments: &[OsString]) -> io::Result<MountConfiguration> {
if !(3..=4).contains(&arguments.len()) {
return Err(io::Error::other(
"usage: lazaret-ignored-workspace WORKSPACE ISOLATED_STATE MOUNTPOINT [GLOBAL_EXCLUDES]",
));
}
Ok(MountConfiguration {
workspace: PathBuf::from(&arguments[0]),
isolated_state: PathBuf::from(&arguments[1]),
mountpoint: PathBuf::from(&arguments[2]),
global_excludes: arguments.get(3).map(PathBuf::from),
threads: std::thread::available_parallelism()
.map(std::num::NonZero::get)
.unwrap_or(1)
.min(8),
})
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
#[test]
fn parser_refuses_incomplete_and_excess_arguments() {
assert!(super::parse(&[]).is_err());
assert!(super::parse(&[OsString::from("one"), OsString::from("two")]).is_err());
assert!(
super::parse(&[
OsString::from("one"),
OsString::from("two"),
OsString::from("three"),
OsString::from("four"),
OsString::from("five"),
])
.is_err()
);
}
}

View file

@ -0,0 +1,99 @@
use std::fs::File;
use std::io;
use std::os::fd::{AsRawFd, RawFd};
use std::path::{Path, PathBuf};
use rustix::fs::{Mode, OFlags};
use rustix::mount::{MountFlags, UnmountFlags, mount_bind, mount_remount, 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 struct GitSentry {
config_target: File,
hook_target: File,
}
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 = BackingRoot::open(&configuration.transient_hooks)?;
let hook_source = transient.duplicate()?;
let hook_target = common.open_entry(
Path::new("hooks"),
OFlags::RDONLY | OFlags::DIRECTORY,
Mode::empty(),
)?;
descriptor_bind(&hook_source, &hook_target)?;
let hook_target = common.open_entry(
Path::new("hooks"),
OFlags::RDONLY | OFlags::DIRECTORY,
Mode::empty(),
)?;
let config_source =
common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())?;
let config_target =
common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())?;
descriptor_bind(&config_source, &config_target)?;
let config_target =
common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())?;
if let Err(error) = descriptor_remount_read_only(&config_target) {
let _ = descriptor_unmount(&hook_target);
return Err(error);
}
Ok(Self {
config_target,
hook_target,
})
}
/// # Errors
/// Returns an error unless both exact protective mountpoints are detached.
pub fn unmount(self) -> io::Result<()> {
let config = descriptor_unmount(&self.config_target);
let hooks = descriptor_unmount(&self.hook_target);
config.and(hooks)
}
}
fn descriptor_bind(source: &File, target: &File) -> io::Result<()> {
Ok(mount_bind(
proc_fd(source.as_raw_fd()),
proc_fd(target.as_raw_fd()),
)?)
}
fn descriptor_remount_read_only(target: &File) -> io::Result<()> {
let result = mount_remount(
proc_fd(target.as_raw_fd()),
MountFlags::BIND
| MountFlags::RDONLY
| MountFlags::NOSUID
| MountFlags::NODEV
| MountFlags::NOEXEC,
"",
);
if result.is_err() {
let _ = unmount(proc_fd(target.as_raw_fd()), UnmountFlags::DETACH);
}
Ok(result?)
}
fn descriptor_unmount(target: &File) -> io::Result<()> {
Ok(unmount(proc_fd(target.as_raw_fd()), UnmountFlags::DETACH)?)
}
fn proc_fd(fd: RawFd) -> PathBuf {
PathBuf::from(format!("/proc/self/fd/{fd}"))
}