feat: add selective Workdir symlink policies
This commit is contained in:
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use globset::Glob;
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult};
|
||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
|
||||
|
||||
/// Execute a bounded glob entirely inside the provider process.
|
||||
pub fn run_glob(
|
||||
@@ -15,7 +15,11 @@ pub fn run_glob(
|
||||
if !root.is_absolute() {
|
||||
return Err(FsError::RelativePath(root.to_path_buf()));
|
||||
}
|
||||
if !access.is_readable(base) {
|
||||
let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
|
||||
path: PathBuf::from(request.path.as_str()),
|
||||
source: error,
|
||||
})?;
|
||||
if !access.is_readable_paths(base, &base_resolved) {
|
||||
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
|
||||
}
|
||||
let matcher = Glob::new(&request.pattern)
|
||||
@@ -26,7 +30,9 @@ pub fn run_glob(
|
||||
walker.hidden(false).follow_links(false);
|
||||
for entry in walker.build().flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() || !access.is_readable(path) {
|
||||
let readable = resolve_access_path(path)
|
||||
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
||||
if !path.is_file() || !readable {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(base).unwrap_or(path);
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
pub use glob::run_glob;
|
||||
pub use local::{run_edit, run_list, run_read, run_stat, run_write};
|
||||
pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
|
||||
pub use operation::*;
|
||||
pub use search::run_grep;
|
||||
|
||||
@@ -22,6 +22,19 @@ pub use search::run_grep;
|
||||
pub trait FsAccessPolicy: Send + Sync {
|
||||
fn is_readable(&self, path: &Path) -> bool;
|
||||
fn is_writable(&self, path: &Path) -> bool;
|
||||
|
||||
/// Authorize both the Workdir-visible path and its provider-resolved
|
||||
/// target. Implementations that do not distinguish symbolic-link identity
|
||||
/// retain resolved-target semantics through the defaults.
|
||||
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
let _ = logical;
|
||||
self.is_readable(resolved)
|
||||
}
|
||||
|
||||
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
let _ = logical;
|
||||
self.is_writable(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
/// First symlink encountered while resolving a provider path.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -18,7 +19,8 @@ pub fn run_stat(
|
||||
) -> Result<StatResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
if !access.is_readable(&path) {
|
||||
let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if !access.is_readable_paths(&path, &resolved) {
|
||||
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
||||
}
|
||||
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
@@ -113,12 +115,8 @@ pub fn run_write(
|
||||
if request.expected_hash.is_some() {
|
||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
|
||||
})?;
|
||||
let parent_logical = logical_parent(&logical);
|
||||
require_access(parent, &parent_logical, access, true, true)?;
|
||||
atomic_write(&path, &request.content, &logical)?;
|
||||
let target = require_access(&path, &logical, access, true, true)?;
|
||||
atomic_write(&target, &request.content, &logical)?;
|
||||
}
|
||||
Ok(WriteResult {
|
||||
bytes_written: request.content.len(),
|
||||
@@ -173,6 +171,7 @@ pub fn run_list(
|
||||
) -> Result<ListResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let logical_base = path.clone();
|
||||
let path = require_access(&path, &logical, access, false, true)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if !metadata.is_dir() {
|
||||
@@ -183,7 +182,15 @@ pub fn run_list(
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||
let absolute = entry.path();
|
||||
if !access.is_readable(&absolute) {
|
||||
let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
|
||||
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
|
||||
})?;
|
||||
let logical_absolute = logical_base.join(relative_to_base);
|
||||
let resolved = match resolve_access_path(&absolute) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !access.is_readable_paths(&logical_absolute, &resolved) {
|
||||
continue;
|
||||
}
|
||||
let link_metadata =
|
||||
@@ -203,7 +210,7 @@ pub fn run_list(
|
||||
} else {
|
||||
EntryKind::Other
|
||||
};
|
||||
let relative = absolute.strip_prefix(root).map_err(|_| {
|
||||
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||
})?;
|
||||
entries.push(ListEntry {
|
||||
@@ -249,18 +256,22 @@ fn require_access(
|
||||
write: bool,
|
||||
allow_symlink_directory: bool,
|
||||
) -> Result<PathBuf, FsError> {
|
||||
if let Some(info) = direct_symlink(path) {
|
||||
if !info.target_exists {
|
||||
return Err(FsError::BrokenSymlink {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
link: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
let symlink = direct_symlink(path);
|
||||
if let Some(info) = symlink.as_ref()
|
||||
&& !info.target_exists
|
||||
{
|
||||
return Err(FsError::BrokenSymlink {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
link: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
|
||||
if let Some(info) = symlink {
|
||||
let allowed = if write {
|
||||
access.is_writable(path)
|
||||
access.is_writable_paths(path, &resolved)
|
||||
} else {
|
||||
access.is_readable(path)
|
||||
access.is_readable_paths(path, &resolved)
|
||||
};
|
||||
if !allowed {
|
||||
return Err(FsError::SymlinkOutOfScope {
|
||||
@@ -275,15 +286,15 @@ fn require_access(
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
return Ok(info.resolved_path);
|
||||
return Ok(resolved);
|
||||
}
|
||||
let allowed = if write {
|
||||
access.is_writable(path)
|
||||
access.is_writable_paths(path, &resolved)
|
||||
} else {
|
||||
access.is_readable(path)
|
||||
access.is_readable_paths(path, &resolved)
|
||||
};
|
||||
if allowed {
|
||||
Ok(path.to_path_buf())
|
||||
Ok(resolved)
|
||||
} else if write {
|
||||
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
|
||||
} else {
|
||||
@@ -291,12 +302,38 @@ fn require_access(
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_parent(path: &FsPath) -> FsPath {
|
||||
let parent = Path::new(path.as_str())
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.to_string_lossy();
|
||||
FsPath::new(parent).unwrap_or_else(|_| FsPath::root())
|
||||
/// Resolve every existing component of an absolute provider path while
|
||||
/// retaining a missing final tail for create operations. Dangling symlinks are
|
||||
/// rejected because no resolved authority identity can be established.
|
||||
pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let mut cursor = path;
|
||||
let mut missing = Vec::<OsString>::new();
|
||||
loop {
|
||||
match fs::canonicalize(cursor) {
|
||||
Ok(mut resolved) => {
|
||||
for component in missing.iter().rev() {
|
||||
resolved.push(component);
|
||||
}
|
||||
return Ok(resolved);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
if fs::symlink_metadata(cursor)
|
||||
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
let name = cursor.file_name().ok_or(error)?;
|
||||
missing.push(name.to_os_string());
|
||||
cursor = cursor.parent().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"path has no existing ancestor",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
|
||||
|
||||
@@ -10,7 +10,9 @@ use ignore::WalkBuilder;
|
||||
use ignore::overrides::{Override, OverrideBuilder};
|
||||
use ignore::types::{Types, TypesBuilder};
|
||||
|
||||
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
|
||||
use crate::{
|
||||
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
|
||||
};
|
||||
|
||||
struct ContentLine {
|
||||
path: PathBuf,
|
||||
@@ -220,14 +222,28 @@ pub fn run_grep(
|
||||
return Err(FsError::RelativePath(base));
|
||||
}
|
||||
let symlink = direct_symlink(&base);
|
||||
if !access.is_readable(&base) {
|
||||
if let Some(info) = symlink.as_ref()
|
||||
&& !info.target_exists
|
||||
{
|
||||
return Err(FsError::BrokenSymlink {
|
||||
path: base.clone(),
|
||||
link: info.link_path.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
});
|
||||
}
|
||||
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
|
||||
if !access.is_readable_paths(&base, &resolved_base) {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| access.is_readable(parent))
|
||||
.and_then(|parent| {
|
||||
resolve_access_path(parent)
|
||||
.ok()
|
||||
.map(|resolved| access.is_readable_paths(parent, &resolved))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
if link_parent_readable {
|
||||
FsError::SymlinkOutOfScope {
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
@@ -240,15 +256,6 @@ pub fn run_grep(
|
||||
FsError::OutOfScope(base.clone())
|
||||
});
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(FsError::BrokenSymlink {
|
||||
path: base.clone(),
|
||||
link: info.link_path.clone(),
|
||||
target: info.target_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||
_ => FsError::io(&base, e),
|
||||
@@ -321,7 +328,9 @@ pub fn run_grep(
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !access.is_readable(path) {
|
||||
let readable = resolve_access_path(path)
|
||||
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
||||
if !readable {
|
||||
continue;
|
||||
}
|
||||
if scan_path(
|
||||
|
||||
Reference in New Issue
Block a user