feat: add support for read-only bind mounts in the linux sandbox (#9112)

### Motivation

- Landlock alone cannot prevent writes to sensitive in-repo files like
`.git/` when the repo root is writable, so explicit mount restrictions
are required for those paths.
- The sandbox must set up any mounts before calling Landlock so Landlock
can still be applied afterwards and the two mechanisms compose
correctly.

### Description

- Add a new `linux-sandbox` helper `apply_read_only_mounts` in
`linux-sandbox/src/mounts.rs` that: unshares namespaces, maps uids/gids
when required, makes mounts private, bind-mounts targets, and remounts
them read-only.
- Wire the mount step into the sandbox flow by calling
`apply_read_only_mounts(...)` before network/seccomp and before applying
Landlock rules in `linux-sandbox/src/landlock.rs`.
This commit is contained in:
viyatb-oai
2026-01-14 08:30:46 -08:00
committed by GitHub
parent bcd7858ced
commit e1447c3009
8 changed files with 676 additions and 13 deletions

View File

@@ -10,6 +10,10 @@ Note that `codex-core` makes some assumptions about certain helper utilities bei
Expects `/usr/bin/sandbox-exec` to be present.
When using the workspace-write sandbox policy, the Seatbelt profile allows
writes under the configured writable roots while keeping `.git` (directory or
pointer file), the resolved `gitdir:` target, and `.codex` read-only.
### Linux
Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details.

View File

@@ -174,6 +174,16 @@ mod tests {
use std::process::Command;
use tempfile::TempDir;
fn assert_seatbelt_denied(stderr: &[u8], path: &Path) {
let stderr = String::from_utf8_lossy(stderr);
let expected = format!("bash: {}: Operation not permitted\n", path.display());
assert!(
stderr == expected
|| stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"),
"unexpected stderr: {stderr}"
);
}
#[test]
fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
// Create a temporary workspace with two writable roots: one containing
@@ -290,10 +300,7 @@ mod tests {
"command to write {} should fail under seatbelt",
&config_toml.display()
);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
format!("bash: {}: Operation not permitted\n", config_toml.display()),
);
assert_seatbelt_denied(&output.stderr, &config_toml);
// Create a similar Seatbelt command that tries to write to a file in
// the .git folder, which should also be blocked.
@@ -324,13 +331,7 @@ mod tests {
"command to write {} should fail under seatbelt",
&pre_commit_hook.display()
);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
format!(
"bash: {}: Operation not permitted\n",
pre_commit_hook.display()
),
);
assert_seatbelt_denied(&output.stderr, &pre_commit_hook);
// Verify that writing a file to the folder containing .git and .codex is allowed.
let allowed_file = vulnerable_root_canonical.join("allowed.txt");
@@ -351,6 +352,12 @@ mod tests {
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success()
&& stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted")
{
return;
}
assert!(
output.status.success(),
"command to write {} should succeed under seatbelt",
@@ -364,6 +371,91 @@ mod tests {
);
}
#[test]
fn create_seatbelt_args_with_read_only_git_pointer_file() {
let tmp = TempDir::new().expect("tempdir");
let worktree_root = tmp.path().join("worktree_root");
fs::create_dir_all(&worktree_root).expect("create worktree_root");
let gitdir = worktree_root.join("actual-gitdir");
fs::create_dir_all(&gitdir).expect("create gitdir");
let gitdir_config = gitdir.join("config");
let gitdir_config_contents = "[core]\n";
fs::write(&gitdir_config, gitdir_config_contents).expect("write gitdir config");
let dot_git = worktree_root.join(".git");
let dot_git_contents = format!("gitdir: {}\n", gitdir.to_string_lossy());
fs::write(&dot_git, &dot_git_contents).expect("write .git pointer");
let cwd = tmp.path().join("cwd");
fs::create_dir_all(&cwd).expect("create cwd");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let shell_command: Vec<String> = [
"bash",
"-c",
"echo 'pwned!' > \"$1\"",
"bash",
dot_git.to_string_lossy().as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let args = create_seatbelt_command_args(shell_command, &policy, &cwd);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
assert_eq!(
dot_git_contents,
String::from_utf8_lossy(&fs::read(&dot_git).expect("read .git pointer")),
".git pointer file should not be modified under seatbelt"
);
assert!(
!output.status.success(),
"command to write {} should fail under seatbelt",
dot_git.display()
);
assert_seatbelt_denied(&output.stderr, &dot_git);
let shell_command_gitdir: Vec<String> = [
"bash",
"-c",
"echo 'pwned!' > \"$1\"",
"bash",
gitdir_config.to_string_lossy().as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let gitdir_args = create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&gitdir_args)
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
assert_eq!(
gitdir_config_contents,
String::from_utf8_lossy(&fs::read(&gitdir_config).expect("read gitdir config")),
"gitdir config should contain its original contents because it should not have been modified"
);
assert!(
!output.status.success(),
"command to write {} should fail under seatbelt",
gitdir_config.display()
);
assert_seatbelt_denied(&output.stderr, &gitdir_config);
}
#[test]
fn create_seatbelt_args_for_cwd_as_git_repo() {
// Create a temporary workspace with two writable roots: one containing

View File

@@ -6,3 +6,52 @@ This crate is responsible for producing:
- a lib crate that exposes the business logic of the executable as `run_main()` so that
- the `codex-exec` CLI can check if its arg0 is `codex-linux-sandbox` and, if so, execute as if it were `codex-linux-sandbox`
- this should also be true of the `codex` multitool CLI
## Git safety mounts (Linux)
When the sandbox policy allows workspace writes, the Linux sandbox uses a user
namespace plus a mount namespace to bind-mount sensitive subpaths read-only
before applying Landlock rules. This keeps Git and Codex metadata immutable
while still allowing writes to other workspace files, including worktree setups
where `.git` is a pointer file.
Protected subpaths under each writable root include:
- `.git` (directory or pointer file)
- the resolved `gitdir:` target when `.git` is a pointer file
- `.codex` when present
### How this plays with Landlock
Mount permissions and Landlock intersect: if a bind mount is read-only, writes
are denied even if Landlock would allow them. For that reason, the sandbox sets
up the read-only mounts *before* calling `landlock_restrict_self()` and then
applies Landlock rules on top.
### Quick manual test
Run the sandbox directly with a workspace-write policy (from a Git repository
root):
```bash
codex-linux-sandbox \
--sandbox-policy-cwd "$PWD" \
--sandbox-policy '{"type":"workspace-write"}' \
-- bash -lc '
set -euo pipefail
echo "should fail" > .git/config && exit 1 || true
echo "should fail" > .git/hooks/pre-commit && exit 1 || true
echo "should fail" > .git/index.lock && exit 1 || true
echo "should fail" > .codex/config.toml && exit 1 || true
echo "ok" > sandbox-write-test.txt
'
```
Expected behavior:
- Writes to `.git/config` fail with `Read-only file system`.
- Creating or modifying files under `.git/hooks/` fails.
- Writing `.git/index.lock` fails (since `.git` is read-only).
- Writes under `.codex/` fail when the directory exists.
- Writing a normal repo file succeeds.

View File

@@ -7,6 +7,8 @@ use codex_core::error::SandboxErr;
use codex_core::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::mounts::apply_read_only_mounts;
use landlock::ABI;
use landlock::Access;
use landlock::AccessFs;
@@ -31,6 +33,10 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> Result<()> {
if !sandbox_policy.has_full_disk_write_access() {
apply_read_only_mounts(sandbox_policy, cwd)?;
}
if !sandbox_policy.has_full_network_access() {
install_network_seccomp_filter_on_current_thread()?;
}

View File

@@ -2,6 +2,8 @@
mod landlock;
#[cfg(target_os = "linux")]
mod linux_run_main;
#[cfg(target_os = "linux")]
mod mounts;
#[cfg(target_os = "linux")]
pub fn run_main() -> ! {

View File

@@ -0,0 +1,255 @@
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
/// Apply read-only bind mounts for protected subpaths before Landlock.
///
/// This unshares mount namespaces (and user namespaces for non-root) so the
/// read-only remounts do not affect the host, then bind-mounts each protected
/// target onto itself and remounts it read-only.
pub(crate) fn apply_read_only_mounts(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result<()> {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
let mount_targets = collect_read_only_mount_targets(&writable_roots)?;
if mount_targets.is_empty() {
return Ok(());
}
// Root can unshare the mount namespace directly; non-root needs a user
// namespace to gain capabilities for remounting.
if is_running_as_root() {
unshare_mount_namespace()?;
} else {
unshare_user_and_mount_namespaces()?;
write_user_namespace_maps()?;
}
make_mounts_private()?;
for target in mount_targets {
// Bind and remount read-only works for both files and directories.
bind_mount_read_only(target.as_path())?;
}
// Drop ambient capabilities acquired from the user namespace so the
// sandboxed command cannot remount or create new bind mounts.
if !is_running_as_root() {
drop_caps()?;
}
Ok(())
}
/// Collect read-only mount targets, resolving worktree `.git` pointer files.
fn collect_read_only_mount_targets(
writable_roots: &[WritableRoot],
) -> Result<Vec<AbsolutePathBuf>> {
let mut targets = Vec::new();
for writable_root in writable_roots {
for ro_subpath in &writable_root.read_only_subpaths {
// The policy expects these paths to exist; surface actionable errors
// rather than silently skipping protections.
if !ro_subpath.as_path().exists() {
return Err(CodexErr::UnsupportedOperation(format!(
"Sandbox expected to protect {path}, but it does not exist. Ensure the repository contains this path or create it before running Codex.",
path = ro_subpath.as_path().display()
)));
}
targets.push(ro_subpath.clone());
// Worktrees and submodules store `.git` as a pointer file; add the
// referenced gitdir as an extra read-only target.
if is_git_pointer_file(ro_subpath) {
let gitdir = resolve_gitdir_from_file(ro_subpath)?;
if !targets
.iter()
.any(|target| target.as_path() == gitdir.as_path())
{
targets.push(gitdir);
}
}
}
}
Ok(targets)
}
/// Detect a `.git` pointer file used by worktrees and submodules.
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(std::ffi::OsStr::new(".git"))
}
/// Resolve a worktree `.git` pointer file to its gitdir path.
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Result<AbsolutePathBuf> {
let contents = std::fs::read_to_string(dot_git.as_path()).map_err(CodexErr::from)?;
let trimmed = contents.trim();
let (_, gitdir_raw) = trimmed.split_once(':').ok_or_else(|| {
CodexErr::UnsupportedOperation(format!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
))
})?;
// `gitdir: <path>` may be relative to the directory containing `.git`.
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
return Err(CodexErr::UnsupportedOperation(format!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
)));
}
let base = dot_git.as_path().parent().ok_or_else(|| {
CodexErr::UnsupportedOperation(format!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
))
})?;
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base)?;
if !gitdir_path.as_path().exists() {
return Err(CodexErr::UnsupportedOperation(format!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
)));
}
Ok(gitdir_path)
}
/// Unshare the mount namespace so mount changes are isolated to the sandboxed process.
fn unshare_mount_namespace() -> Result<()> {
let result = unsafe { libc::unshare(libc::CLONE_NEWNS) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Unshare user + mount namespaces so the process can remount read-only without privileges.
fn unshare_user_and_mount_namespaces() -> Result<()> {
let result = unsafe { libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
fn is_running_as_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[repr(C)]
struct CapUserHeader {
version: u32,
pid: i32,
}
#[repr(C)]
struct CapUserData {
effective: u32,
permitted: u32,
inheritable: u32,
}
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
/// Map the current uid/gid to root inside the user namespace.
fn write_user_namespace_maps() -> Result<()> {
write_proc_file("/proc/self/setgroups", "deny\n")?;
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
write_proc_file("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
write_proc_file("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
Ok(())
}
/// Drop all capabilities in the current user namespace.
fn drop_caps() -> Result<()> {
let mut header = CapUserHeader {
version: LINUX_CAPABILITY_VERSION_3,
pid: 0,
};
let data = [
CapUserData {
effective: 0,
permitted: 0,
inheritable: 0,
},
CapUserData {
effective: 0,
permitted: 0,
inheritable: 0,
},
];
// Use syscall directly to avoid libc capability symbols that are missing on musl.
let result = unsafe { libc::syscall(libc::SYS_capset, &mut header, data.as_ptr()) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Write a small procfs file, returning a sandbox error on failure.
fn write_proc_file(path: &str, contents: impl AsRef<[u8]>) -> Result<()> {
std::fs::write(path, contents)?;
Ok(())
}
/// Ensure mounts are private so remounting does not propagate outside the namespace.
fn make_mounts_private() -> Result<()> {
let root = CString::new("/").map_err(|_| {
CodexErr::UnsupportedOperation("Sandbox mount path contains NUL byte: /".to_string())
})?;
let result = unsafe {
libc::mount(
std::ptr::null(),
root.as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
)
};
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Bind-mount a path onto itself and remount read-only.
fn bind_mount_read_only(path: &Path) -> Result<()> {
let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
CodexErr::UnsupportedOperation(format!(
"Sandbox mount path contains NUL byte: {path}",
path = path.display()
))
})?;
let bind_result = unsafe {
libc::mount(
c_path.as_ptr(),
c_path.as_ptr(),
std::ptr::null(),
libc::MS_BIND,
std::ptr::null(),
)
};
if bind_result != 0 {
return Err(std::io::Error::last_os_error().into());
}
let remount_result = unsafe {
libc::mount(
c_path.as_ptr(),
c_path.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REMOUNT | libc::MS_RDONLY,
std::ptr::null(),
)
};
if remount_result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}

View File

@@ -1,4 +1,5 @@
#![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used)]
use codex_core::config::types::ShellEnvironmentPolicy;
use codex_core::error::CodexErr;
use codex_core::error::SandboxErr;
@@ -11,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use tokio::process::Command;
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
@@ -79,6 +81,51 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
}
}
#[expect(clippy::expect_used)]
async fn assert_write_blocked(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let cwd = std::env::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
cwd,
expiration: timeout_ms.into(),
env: create_env_from_core_vars(),
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
arg0: None,
};
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots
.iter()
.map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap())
.collect(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
let result = process_exec_tool_call(
params,
&sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
None,
)
.await;
match result {
Ok(output) => {
if output.exit_code == 0 {
panic!("expected command to fail, but exit code was 0");
}
}
Err(CodexErr::Sandbox(SandboxErr::Denied { .. })) => {}
Err(err) => panic!("expected sandbox denial, got: {err:?}"),
}
}
#[tokio::test]
async fn test_root_read() {
run_cmd(&["ls", "-l", "/bin"], &[], SHORT_TIMEOUT_MS).await;
@@ -127,6 +174,134 @@ async fn test_writable_root() {
.await;
}
#[tokio::test]
async fn test_git_dir_write_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
Command::new("git")
.arg("init")
.arg(".")
.current_dir(repo_root)
.output()
.await
.expect("git init .");
let git_config = repo_root.join(".git").join("config");
let git_index_lock = repo_root.join(".git").join("index.lock");
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", git_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", git_index_lock.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_git_dir_move_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
Command::new("git")
.arg("init")
.arg(".")
.current_dir(repo_root)
.output()
.await
.expect("git init .");
let git_dir = repo_root.join(".git");
let git_dir_backup = repo_root.join(".git.bak");
assert_write_blocked(
&[
"bash",
"-lc",
&format!(
"mv {} {}",
git_dir.to_string_lossy(),
git_dir_backup.to_string_lossy()
),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_codex_dir_write_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
std::fs::create_dir_all(repo_root.join(".codex")).unwrap();
let codex_config = repo_root.join(".codex").join("config.toml");
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", codex_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_git_pointer_file_blocks_gitdir_writes() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
let gitdir = repo_root.join("actual-gitdir");
std::fs::create_dir_all(&gitdir).unwrap();
let gitdir_config = gitdir.join("config");
std::fs::write(&gitdir_config, "[core]\n\trepositoryformatversion = 0\n").unwrap();
std::fs::write(
repo_root.join(".git"),
format!("gitdir: {}\n", gitdir.to_string_lossy()),
)
.unwrap();
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", gitdir_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", repo_root.join(".git").to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
#[should_panic(expected = "Sandbox(Timeout")]
async fn test_timeout() {

View File

@@ -4,6 +4,7 @@
//! between user and agent.
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
@@ -505,12 +506,26 @@ impl SandboxPolicy {
roots
.into_iter()
.map(|writable_root| {
let mut subpaths = Vec::new();
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
if top_level_git.as_path().is_dir() {
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
&& !subpaths
.iter()
.any(|subpath| subpath.as_path() == gitdir.as_path())
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
#[allow(clippy::expect_used)]
@@ -531,6 +546,71 @@ impl SandboxPolicy {
}
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
let contents = match std::fs::read_to_string(dot_git.as_path()) {
Ok(contents) => contents,
Err(err) => {
error!(
"Failed to read {path} for gitdir pointer: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
let trimmed = contents.trim();
let (_, gitdir_raw) = match trimmed.split_once(':') {
Some(parts) => parts,
None => {
error!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
error!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
);
return None;
}
let base = match dot_git.as_path().parent() {
Some(base) => base,
None => {
error!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_path = match AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base) {
Ok(path) => path,
Err(err) => {
error!(
"Failed to resolve gitdir path {gitdir_raw} from {path}: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
if !gitdir_path.as_path().exists() {
error!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
);
return None;
}
Some(gitdir_path)
}
/// Event Queue Entry - events from agent
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Event {