Implement fail-closed ignored workspace routing
This commit is contained in:
parent
ba6a075839
commit
95104fa4e7
8 changed files with 6024 additions and 6 deletions
339
crates/lazaret-ignored-workspace/src/backing.rs
Normal file
339
crates/lazaret-ignored-workspace/src/backing.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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],
|
||||
}
|
||||
959
crates/lazaret-ignored-workspace/src/classifier.rs
Normal file
959
crates/lazaret-ignored-workspace/src/classifier.rs
Normal 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(¤t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1742
crates/lazaret-ignored-workspace/src/filesystem.rs
Normal file
1742
crates/lazaret-ignored-workspace/src/filesystem.rs
Normal file
File diff suppressed because it is too large
Load diff
8
crates/lazaret-ignored-workspace/src/lib.rs
Normal file
8
crates/lazaret-ignored-workspace/src/lib.rs
Normal 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};
|
||||
57
crates/lazaret-ignored-workspace/src/main.rs
Normal file
57
crates/lazaret-ignored-workspace/src/main.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
99
crates/lazaret-ignored-workspace/src/sentry.rs
Normal file
99
crates/lazaret-ignored-workspace/src/sentry.rs
Normal 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}"))
|
||||
}
|
||||
Loading…
Reference in a new issue