feat: add interactive read-write external workdir sharing

This commit is contained in:
2026-09-29 05:53:32 +09:00
parent 54c1174698
commit 24be69918d
12 changed files with 1510 additions and 242 deletions
+169
View File
@@ -154,6 +154,105 @@ pub fn open_beneath_no_symlinks(_root: &Path, _path: &Path) -> std::io::Result<s
))
}
#[cfg(target_os = "linux")]
pub fn atomic_write_beneath_no_symlinks_at(
root: &std::fs::File,
relative: &Path,
content: &[u8],
before_commit: impl FnOnce() -> std::io::Result<()>,
) -> std::io::Result<()> {
use std::ffi::{CString, OsString};
use std::io::Write;
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
use std::os::unix::ffi::OsStrExt;
if relative.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
}
let mut components = relative.components().peekable();
let mut parent = root.try_clone()?;
let mut file_name = None::<OsString>;
while let Some(component) = components.next() {
let std::path::Component::Normal(name) = component else {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
};
if components.peek().is_none() {
file_name = Some(name.to_os_string());
break;
}
let name = CString::new(name.as_bytes()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL")
})?;
// SAFETY: `name` is a valid C string and `parent` remains open.
let mut fd: RawFd = unsafe {
libc::openat(
parent.as_raw_fd(),
name.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
)
};
if fd < 0 {
let error = std::io::Error::last_os_error();
if error.kind() != std::io::ErrorKind::NotFound {
return Err(error);
}
// SAFETY: same valid directory descriptor and component C string.
let created = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o755) };
if created < 0 {
let create_error = std::io::Error::last_os_error();
if create_error.kind() != std::io::ErrorKind::AlreadyExists {
return Err(create_error);
}
}
// SAFETY: same as the first openat; a racing symlink is rejected by O_NOFOLLOW.
fd = unsafe {
libc::openat(
parent.as_raw_fd(),
name.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
}
// SAFETY: fd is a fresh descriptor returned by openat.
parent = unsafe { std::fs::File::from_raw_fd(fd) };
}
let file_name = file_name.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "file path is empty")
})?;
let parent_path = PathBuf::from(format!("/proc/self/fd/{}", parent.as_raw_fd()));
let mut temporary = tempfile::NamedTempFile::new_in(&parent_path)?;
temporary.write_all(content)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
before_commit()?;
temporary
.persist(parent_path.join(file_name))
.map_err(|error| error.error)?;
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn atomic_write_beneath_no_symlinks_at(
_root: &std::fs::File,
_relative: &Path,
_content: &[u8],
_before_commit: impl FnOnce() -> std::io::Result<()>,
) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"root-confined no-symlink write is unavailable on this platform",
))
}
/// Keeps a descriptor-backed traversal path alive for the duration of a walk.
#[derive(Debug)]
pub struct FsTraversalRoot {
@@ -218,6 +317,30 @@ pub trait FsAccessPolicy: Send + Sync {
std::fs::metadata(resolved)
}
/// Atomically replace or create an already-authorized writable file.
/// Capability providers override this to keep parent traversal descriptor-
/// confined through the final rename.
fn atomic_write_file(
&self,
_logical: &Path,
resolved: &Path,
content: &[u8],
) -> std::io::Result<()> {
use std::io::Write;
let parent = resolved.parent().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "file path has no parent")
})?;
std::fs::create_dir_all(parent)?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(content)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
self.check_cancelled()?;
temporary.persist(resolved).map_err(|error| error.error)?;
Ok(())
}
/// Open an already-authorized directory for bounded enumeration.
/// Capability providers override this to bind traversal to a confined
/// directory descriptor rather than reopening a mutable path.
@@ -1026,6 +1149,52 @@ mod tests {
));
}
#[cfg(target_os = "linux")]
#[test]
fn descriptor_confined_atomic_write_rejects_symlinks_and_survives_root_rename() {
use std::os::unix::fs::symlink;
let parent = tempfile::tempdir().unwrap();
let selected = parent.path().join("selected");
std::fs::create_dir(&selected).unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
symlink(outside.path(), selected.join("escape")).unwrap();
let root = open_root_no_symlinks(&selected).unwrap();
let error = atomic_write_beneath_no_symlinks_at(
&root,
Path::new("escape/secret.txt"),
b"escaped",
|| Ok(()),
)
.unwrap_err();
assert!(matches!(
error.kind(),
std::io::ErrorKind::NotADirectory | std::io::ErrorKind::PermissionDenied
));
assert_eq!(
std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
"secret"
);
std::fs::rename(&selected, parent.path().join("approved-original")).unwrap();
std::fs::create_dir(&selected).unwrap();
atomic_write_beneath_no_symlinks_at(
&root,
Path::new("nested/created.txt"),
b"approved",
|| Ok(()),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(parent.path().join("approved-original/nested/created.txt"))
.unwrap(),
"approved"
);
assert!(!selected.join("nested/created.txt").exists());
}
#[test]
fn grep_content_groups_lines_by_file_and_marks_matches() {
let temp = tempfile::tempdir().unwrap();
+24 -24
View File
@@ -1,6 +1,6 @@
use std::ffi::OsString;
use std::fs;
use std::io::{BufReader, Read, Write};
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
@@ -208,21 +208,32 @@ pub fn run_write(
let created = !path.exists();
if path.exists() {
let target = require_access(&path, &logical, access, true, false)?;
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
let metadata = access
.read_metadata(&path, &target)
.map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
}
let actual = hash_bytes(&fs::read(&target).map_err(|error| map_io(&logical, error))?);
let mut bytes = Vec::new();
access
.open_read_file(&path, &target)
.and_then(|mut file| file.read_to_end(&mut bytes))
.map_err(|error| map_io(&logical, error))?;
let actual = hash_bytes(&bytes);
if request.expected_hash != Some(actual) {
return Err(FsError::Conflict(logical.as_str().to_string()));
}
atomic_write(&target, &request.content, &logical)?;
access
.atomic_write_file(&path, &target, &request.content)
.map_err(|error| map_io(&logical, error))?;
} else {
if request.expected_hash.is_some() {
return Err(FsError::Conflict(logical.as_str().to_string()));
}
let target = require_access(&path, &logical, access, true, true)?;
atomic_write(&target, &request.content, &logical)?;
access
.atomic_write_file(&path, &target, &request.content)
.map_err(|error| map_io(&logical, error))?;
}
Ok(WriteResult {
bytes_written: request.content.len(),
@@ -241,7 +252,11 @@ pub fn run_edit(
.map_err(|error| map_io(&logical, error))?;
let path = resolve(root, &logical)?;
let target = require_access(&path, &logical, access, true, false)?;
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
let mut bytes = Vec::new();
access
.open_read_file(&path, &target)
.and_then(|mut file| file.read_to_end(&mut bytes))
.map_err(|error| map_io(&logical, error))?;
let actual_hash = hash_bytes(&bytes);
if actual_hash != request.expected_hash {
return Err(FsError::Conflict(logical.as_str().to_string()));
@@ -265,7 +280,9 @@ pub fn run_edit(
} else {
content.replacen(&request.old_string, &request.new_string, 1)
};
atomic_write(&target, edited.as_bytes(), &logical)?;
access
.atomic_write_file(&path, &target, edited.as_bytes())
.map_err(|error| map_io(&logical, error))?;
Ok(EditResult {
replacements: if request.replace_all { occurrences } else { 1 },
bytes_written: edited.len(),
@@ -464,23 +481,6 @@ pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
}
}
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
let parent = path
.parent()
.ok_or_else(|| FsError::InvalidArgument(format!("{} has no parent", logical.as_str())))?;
fs::create_dir_all(parent).map_err(|error| map_io(logical, error))?;
let mut temporary =
tempfile::NamedTempFile::new_in(parent).map_err(|error| map_io(logical, error))?;
temporary
.write_all(content)
.map_err(|error| map_io(logical, error))?;
temporary.flush().map_err(|error| map_io(logical, error))?;
temporary
.persist(path)
.map_err(|error| map_io(logical, error.error))?;
Ok(())
}
fn hash_bytes(content: &[u8]) -> ContentHash {
Sha256::digest(content).into()
}