feat: Toolsのシンボリックリンク対応
This commit is contained in:
parent
9194b10d50
commit
25d22fc4af
|
|
@ -16,6 +16,47 @@ pub enum ToolsError {
|
||||||
#[error("path is outside allowed scope: {}", .0.display())]
|
#[error("path is outside allowed scope: {}", .0.display())]
|
||||||
OutOfScope(PathBuf),
|
OutOfScope(PathBuf),
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Pod {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
|
||||||
|
.path.display(),
|
||||||
|
.target.display()
|
||||||
|
)]
|
||||||
|
SymlinkOutOfScope {
|
||||||
|
path: PathBuf,
|
||||||
|
target: PathBuf,
|
||||||
|
required_permission: &'static str,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"broken symlink while resolving {}: {} -> {} (target does not exist); recreate the symlink with an absolute target or a correct relative target",
|
||||||
|
.path.display(),
|
||||||
|
.link.display(),
|
||||||
|
.target.display()
|
||||||
|
)]
|
||||||
|
BrokenSymlink {
|
||||||
|
path: PathBuf,
|
||||||
|
link: PathBuf,
|
||||||
|
target: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"path resolves through a symlink to a directory, not a file: {} -> {}",
|
||||||
|
.path.display(),
|
||||||
|
.target.display()
|
||||||
|
)]
|
||||||
|
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"{tool} does not follow symlink directories: {} -> {}; use the resolved target path directly, or add the target to read scope and reference it without the symlink",
|
||||||
|
.path.display(),
|
||||||
|
.target.display()
|
||||||
|
)]
|
||||||
|
SymlinkDirectoryNotTraversed {
|
||||||
|
tool: &'static str,
|
||||||
|
path: PathBuf,
|
||||||
|
target: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("path is read-only in this scope: {}", .0.display())]
|
#[error("path is read-only in this scope: {}", .0.display())]
|
||||||
ReadOnly(PathBuf),
|
ReadOnly(PathBuf),
|
||||||
|
|
||||||
|
|
@ -73,6 +114,10 @@ impl From<ToolsError> for ToolError {
|
||||||
match err {
|
match err {
|
||||||
RelativePath(_)
|
RelativePath(_)
|
||||||
| OutOfScope(_)
|
| OutOfScope(_)
|
||||||
|
| SymlinkOutOfScope { .. }
|
||||||
|
| BrokenSymlink { .. }
|
||||||
|
| SymlinkTargetIsDirectory { .. }
|
||||||
|
| SymlinkDirectoryNotTraversed { .. }
|
||||||
| ReadOnly(_)
|
| ReadOnly(_)
|
||||||
| IsDirectory(_)
|
| IsDirectory(_)
|
||||||
| NotRead(_)
|
| NotRead(_)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use manifest::Scope;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
use crate::scoped_fs::ScopedFs;
|
use crate::scoped_fs::{ScopedFs, direct_symlink};
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Recursively find files matching a glob pattern \
|
const DESCRIPTION: &str = "Recursively find files matching a glob pattern \
|
||||||
(e.g. \"**/*.rs\"). Results are sorted by modification time, newest first, \
|
(e.g. \"**/*.rs\"). Results are sorted by modification time, newest first, \
|
||||||
|
|
@ -98,8 +98,52 @@ fn run_glob(base: &Path, pattern: &str, scope: &Scope) -> Result<Vec<PathBuf>, T
|
||||||
if !base.is_absolute() {
|
if !base.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(base.to_path_buf()));
|
return Err(ToolsError::RelativePath(base.to_path_buf()));
|
||||||
}
|
}
|
||||||
if !base.exists() {
|
let symlink = direct_symlink(base);
|
||||||
return Err(ToolsError::NotFound(base.to_path_buf()));
|
if !scope.is_readable(base) {
|
||||||
|
return Err(if let Some(info) = symlink.as_ref() {
|
||||||
|
let link_parent_readable = info
|
||||||
|
.link_path
|
||||||
|
.parent()
|
||||||
|
.map(|parent| scope.is_readable(parent))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if info.target_exists && link_parent_readable {
|
||||||
|
ToolsError::SymlinkOutOfScope {
|
||||||
|
path: base.to_path_buf(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
required_permission: "read",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolsError::OutOfScope(base.to_path_buf())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolsError::OutOfScope(base.to_path_buf())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(info) = symlink.as_ref() {
|
||||||
|
if !info.target_exists {
|
||||||
|
return Err(ToolsError::BrokenSymlink {
|
||||||
|
path: base.to_path_buf(),
|
||||||
|
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 => ToolsError::NotFound(base.to_path_buf()),
|
||||||
|
_ => ToolsError::io(base, e),
|
||||||
|
})?;
|
||||||
|
if !base_meta.is_dir() {
|
||||||
|
return Err(ToolsError::InvalidArgument(format!(
|
||||||
|
"glob search path is not a directory: {}",
|
||||||
|
base.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(info) = symlink.as_ref() {
|
||||||
|
return Err(ToolsError::SymlinkDirectoryNotTraversed {
|
||||||
|
tool: "Glob",
|
||||||
|
path: base.to_path_buf(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let glob = globset::Glob::new(pattern)
|
let glob = globset::Glob::new(pattern)
|
||||||
|
|
@ -296,4 +340,34 @@ mod tests {
|
||||||
assert!(body.contains(".hidden.rs"));
|
assert!(body.contains(".hidden.rs"));
|
||||||
assert!(body.contains("visible.rs"));
|
assert!(body.contains("visible.rs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn glob_reports_scope_inside_symlink_directory_is_not_traversed() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let (dir, fs) = setup();
|
||||||
|
let target = dir.path().join("target-dir");
|
||||||
|
touch(&target.join("visible.rs"), "");
|
||||||
|
let link = dir.path().join("external-project");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let def = glob_tool(fs);
|
||||||
|
let (_, tool) = def();
|
||||||
|
let inp = serde_json::json!({
|
||||||
|
"path": link.to_str().unwrap(),
|
||||||
|
"pattern": "**/*.rs",
|
||||||
|
});
|
||||||
|
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("Glob does not follow symlink directories"),
|
||||||
|
"{msg}"
|
||||||
|
);
|
||||||
|
assert!(msg.contains(&link.display().to_string()), "{msg}");
|
||||||
|
assert!(
|
||||||
|
msg.contains(&target.canonicalize().unwrap().display().to_string()),
|
||||||
|
"{msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use manifest::Scope;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
use crate::scoped_fs::ScopedFs;
|
use crate::scoped_fs::{ScopedFs, direct_symlink};
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Recursive regex search across files, powered by \
|
const DESCRIPTION: &str = "Recursive regex search across files, powered by \
|
||||||
ripgrep. Supports file filtering (`glob`, `type`), context lines, multiline \
|
ripgrep. Supports file filtering (`glob`, `type`), context lines, multiline \
|
||||||
|
|
@ -255,8 +255,52 @@ fn run_grep(default_base: PathBuf, p: GrepParams, scope: &Scope) -> Result<GrepR
|
||||||
if !base.is_absolute() {
|
if !base.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(base));
|
return Err(ToolsError::RelativePath(base));
|
||||||
}
|
}
|
||||||
if !base.exists() {
|
let symlink = direct_symlink(&base);
|
||||||
return Err(ToolsError::NotFound(base));
|
if !scope.is_readable(&base) {
|
||||||
|
return Err(if let Some(info) = symlink.as_ref() {
|
||||||
|
let link_parent_readable = info
|
||||||
|
.link_path
|
||||||
|
.parent()
|
||||||
|
.map(|parent| scope.is_readable(parent))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if info.target_exists && link_parent_readable {
|
||||||
|
ToolsError::SymlinkOutOfScope {
|
||||||
|
path: base.clone(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
required_permission: "read",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolsError::OutOfScope(base.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolsError::OutOfScope(base.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(info) = symlink.as_ref() {
|
||||||
|
if !info.target_exists {
|
||||||
|
return Err(ToolsError::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 => ToolsError::NotFound(base.clone()),
|
||||||
|
_ => ToolsError::io(&base, e),
|
||||||
|
})?;
|
||||||
|
if !base_meta.is_dir() {
|
||||||
|
return Err(ToolsError::InvalidArgument(format!(
|
||||||
|
"grep search path is not a directory: {}",
|
||||||
|
base.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(info) = symlink.as_ref() {
|
||||||
|
return Err(ToolsError::SymlinkDirectoryNotTraversed {
|
||||||
|
tool: "Grep",
|
||||||
|
path: base.clone(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut wb = WalkBuilder::new(&base);
|
let mut wb = WalkBuilder::new(&base);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,23 @@ pub struct WriteOutcome {
|
||||||
pub created: bool,
|
pub created: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First symlink encountered while resolving a path.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SymlinkInfo {
|
||||||
|
/// The symlink path as it appears in the original path chain.
|
||||||
|
pub link_path: PathBuf,
|
||||||
|
/// The symlink target resolved relative to the symlink's parent when the
|
||||||
|
/// link stores a relative target.
|
||||||
|
pub target_path: PathBuf,
|
||||||
|
/// Best-effort resolved form of the full requested path after replacing
|
||||||
|
/// the symlink component with its target and rejoining any remaining tail.
|
||||||
|
/// Existing targets are canonicalized; broken targets are left absolute.
|
||||||
|
pub resolved_path: PathBuf,
|
||||||
|
/// Whether the symlink target itself exists. A missing target is a broken
|
||||||
|
/// symlink even when the symlink lives inside an allowed scope.
|
||||||
|
pub target_exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl ScopedFs {
|
impl ScopedFs {
|
||||||
/// Create a new [`ScopedFs`] wrapping `scope` and `pwd` in a fresh
|
/// Create a new [`ScopedFs`] wrapping `scope` and `pwd` in a fresh
|
||||||
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
||||||
|
|
@ -92,15 +109,34 @@ impl ScopedFs {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
if !self.inner.scope.load().is_readable(path) {
|
let symlink = first_symlink(path);
|
||||||
return Err(ToolsError::OutOfScope(path.to_path_buf()));
|
let scope = self.inner.scope.load();
|
||||||
|
if !scope.is_readable(path) {
|
||||||
|
return Err(symlink_out_of_scope_or_plain(
|
||||||
|
path,
|
||||||
|
symlink.as_ref(),
|
||||||
|
"read",
|
||||||
|
&scope,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(info) = symlink.as_ref() {
|
||||||
|
if !info.target_exists {
|
||||||
|
return Err(broken_symlink_error(path, info));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
||||||
_ => ToolsError::io(path, e),
|
_ => ToolsError::io(path, e),
|
||||||
})?;
|
})?;
|
||||||
if meta.is_dir() {
|
if meta.is_dir() {
|
||||||
return Err(ToolsError::IsDirectory(path.to_path_buf()));
|
return Err(if let Some(info) = symlink.as_ref() {
|
||||||
|
ToolsError::SymlinkTargetIsDirectory {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolsError::IsDirectory(path.to_path_buf())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
std::fs::read(path).map_err(|e| ToolsError::io(path, e))
|
std::fs::read(path).map_err(|e| ToolsError::io(path, e))
|
||||||
}
|
}
|
||||||
|
|
@ -125,28 +161,50 @@ impl ScopedFs {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
|
let symlink = first_symlink(path);
|
||||||
let scope = self.inner.scope.load();
|
let scope = self.inner.scope.load();
|
||||||
if !scope.is_writable(path) {
|
if !scope.is_writable(path) {
|
||||||
return Err(if scope.is_readable(path) {
|
return Err(if scope.is_readable(path) {
|
||||||
ToolsError::ReadOnly(path.to_path_buf())
|
ToolsError::ReadOnly(path.to_path_buf())
|
||||||
} else {
|
} else {
|
||||||
ToolsError::OutOfScope(path.to_path_buf())
|
symlink_out_of_scope_or_plain(path, symlink.as_ref(), "write", &scope)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
drop(scope);
|
drop(scope);
|
||||||
|
|
||||||
|
if let Some(info) = symlink.as_ref() {
|
||||||
|
if !info.target_exists {
|
||||||
|
return Err(broken_symlink_error(path, info));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reject existing directory targets.
|
// Reject existing directory targets.
|
||||||
match std::fs::metadata(path) {
|
match std::fs::metadata(path) {
|
||||||
Ok(meta) if meta.is_dir() => {
|
Ok(meta) if meta.is_dir() => {
|
||||||
return Err(ToolsError::IsDirectory(path.to_path_buf()));
|
return Err(if let Some(info) = symlink.as_ref() {
|
||||||
|
ToolsError::SymlinkTargetIsDirectory {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolsError::IsDirectory(path.to_path_buf())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let existed = path.exists();
|
let existed = path.exists();
|
||||||
|
let write_target = if existed {
|
||||||
|
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||||
|
} else {
|
||||||
|
path.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
let parent = path.parent().ok_or_else(|| {
|
let parent = write_target.parent().ok_or_else(|| {
|
||||||
ToolsError::InvalidArgument(format!("path has no parent directory: {}", path.display()))
|
ToolsError::InvalidArgument(format!(
|
||||||
|
"path has no parent directory: {}",
|
||||||
|
write_target.display()
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| ToolsError::io(parent, e))?;
|
std::fs::create_dir_all(parent).map_err(|e| ToolsError::io(parent, e))?;
|
||||||
|
|
@ -160,12 +218,12 @@ impl ScopedFs {
|
||||||
let mut tmp = tempfile::NamedTempFile::new_in(tmp_parent)
|
let mut tmp = tempfile::NamedTempFile::new_in(tmp_parent)
|
||||||
.map_err(|e| ToolsError::io(tmp_parent, e))?;
|
.map_err(|e| ToolsError::io(tmp_parent, e))?;
|
||||||
tmp.write_all(content)
|
tmp.write_all(content)
|
||||||
.map_err(|e| ToolsError::io(path, e))?;
|
.map_err(|e| ToolsError::io(&write_target, e))?;
|
||||||
tmp.as_file()
|
tmp.as_file()
|
||||||
.sync_all()
|
.sync_all()
|
||||||
.map_err(|e| ToolsError::io(path, e))?;
|
.map_err(|e| ToolsError::io(&write_target, e))?;
|
||||||
tmp.persist(path)
|
tmp.persist(&write_target)
|
||||||
.map_err(|e| ToolsError::io(path, e.error))?;
|
.map_err(|e| ToolsError::io(&write_target, e.error))?;
|
||||||
|
|
||||||
Ok(WriteOutcome {
|
Ok(WriteOutcome {
|
||||||
bytes_written: content.len(),
|
bytes_written: content.len(),
|
||||||
|
|
@ -174,6 +232,93 @@ impl ScopedFs {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the first symlink component in `path`, if one exists.
|
||||||
|
///
|
||||||
|
/// The function only inspects existing path components. It intentionally uses
|
||||||
|
/// `symlink_metadata` so the symlink itself can be diagnosed before any later
|
||||||
|
/// `metadata` call follows it and collapses the reason into `NotFound` or
|
||||||
|
/// `OutOfScope`.
|
||||||
|
pub fn first_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cur = PathBuf::new();
|
||||||
|
let mut components = path.components().peekable();
|
||||||
|
while let Some(component) = components.next() {
|
||||||
|
cur.push(component.as_os_str());
|
||||||
|
let meta = std::fs::symlink_metadata(&cur).ok()?;
|
||||||
|
if !meta.file_type().is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_target = std::fs::read_link(&cur).ok()?;
|
||||||
|
let target_path = if raw_target.is_absolute() {
|
||||||
|
raw_target
|
||||||
|
} else {
|
||||||
|
cur.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("/"))
|
||||||
|
.join(raw_target)
|
||||||
|
};
|
||||||
|
let target_exists = target_path.exists();
|
||||||
|
let mut resolved_path = target_path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| target_path.clone());
|
||||||
|
for remaining in components {
|
||||||
|
resolved_path.push(remaining.as_os_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(SymlinkInfo {
|
||||||
|
link_path: cur,
|
||||||
|
target_path,
|
||||||
|
resolved_path,
|
||||||
|
target_exists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn direct_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||||
|
let meta = std::fs::symlink_metadata(path).ok()?;
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
first_symlink(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symlink_out_of_scope_or_plain(
|
||||||
|
path: &Path,
|
||||||
|
symlink: Option<&SymlinkInfo>,
|
||||||
|
required_permission: &'static str,
|
||||||
|
scope: &Scope,
|
||||||
|
) -> ToolsError {
|
||||||
|
if let Some(info) = symlink {
|
||||||
|
let link_parent_readable = info
|
||||||
|
.link_path
|
||||||
|
.parent()
|
||||||
|
.map(|parent| scope.is_readable(parent))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if info.target_exists && link_parent_readable {
|
||||||
|
return ToolsError::SymlinkOutOfScope {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
required_permission,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolsError::OutOfScope(path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broken_symlink_error(path: &Path, info: &SymlinkInfo) -> ToolsError {
|
||||||
|
ToolsError::BrokenSymlink {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
link: info.link_path.clone(),
|
||||||
|
target: info.target_path.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -241,6 +386,90 @@ mod tests {
|
||||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn read_bytes_reports_broken_symlink_target() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let fs = make_fs(&dir);
|
||||||
|
let link = dir.path().join("external-project");
|
||||||
|
let target = dir.path().join("missing-target");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let err = fs.read_bytes(&link).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
ToolsError::BrokenSymlink { ref path, link: ref err_link, target: ref err_target }
|
||||||
|
if path == &link && err_link == &link && err_target == &target
|
||||||
|
),
|
||||||
|
"expected broken symlink diagnostic, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn read_bytes_reports_symlink_target_outside_scope() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let outside = TempDir::new().unwrap();
|
||||||
|
let target = outside.path().join("target.txt");
|
||||||
|
fs::write(&target, b"secret").unwrap();
|
||||||
|
let link = dir.path().join("outside-repo.txt");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let fs = make_fs(&dir);
|
||||||
|
let err = fs.read_bytes(&link).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
|
||||||
|
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||||
|
),
|
||||||
|
"expected symlink out-of-scope diagnostic, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn read_bytes_allows_symlink_file_when_target_is_inside_scope() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let target = dir.path().join("target.txt");
|
||||||
|
fs::write(&target, b"visible").unwrap();
|
||||||
|
let link = dir.path().join("link.txt");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let fs = make_fs(&dir);
|
||||||
|
assert_eq!(fs.read_bytes(&link).unwrap(), b"visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn read_bytes_reports_symlink_to_directory_as_wrong_file_type() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let target_dir = dir.path().join("target-dir");
|
||||||
|
fs::create_dir(&target_dir).unwrap();
|
||||||
|
let link = dir.path().join("dir-link");
|
||||||
|
symlink(&target_dir, &link).unwrap();
|
||||||
|
|
||||||
|
let fs = make_fs(&dir);
|
||||||
|
let err = fs.read_bytes(&link).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
ToolsError::SymlinkTargetIsDirectory { ref path, ref target }
|
||||||
|
if path == &link && target == &target_dir.canonicalize().unwrap()
|
||||||
|
),
|
||||||
|
"expected symlink directory type diagnostic, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// write
|
// write
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -267,6 +496,53 @@ mod tests {
|
||||||
assert_eq!(fs::read(&file).unwrap(), b"new");
|
assert_eq!(fs::read(&file).unwrap(), b"new");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn write_existing_symlink_file_updates_in_scope_target() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let fs = make_fs(&dir);
|
||||||
|
let target = dir.path().join("target.txt");
|
||||||
|
fs::write(&target, b"old").unwrap();
|
||||||
|
let link = dir.path().join("link.txt");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let out = fs.write(&link, b"new").unwrap();
|
||||||
|
assert!(!out.created);
|
||||||
|
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||||
|
assert!(
|
||||||
|
fs::symlink_metadata(&link)
|
||||||
|
.unwrap()
|
||||||
|
.file_type()
|
||||||
|
.is_symlink()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn write_reports_symlink_target_outside_scope() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let outside = TempDir::new().unwrap();
|
||||||
|
let target = outside.path().join("target.txt");
|
||||||
|
fs::write(&target, b"secret").unwrap();
|
||||||
|
let link = dir.path().join("outside-repo.txt");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let fs = make_fs(&dir);
|
||||||
|
let err = fs.write(&link, b"new").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
|
||||||
|
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||||
|
),
|
||||||
|
"expected write symlink out-of-scope diagnostic, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_rejects_out_of_scope() {
|
fn write_rejects_out_of_scope() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,13 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
format!("{read_err}").contains("outside allowed scope"),
|
format!("{read_err}").contains("outside allowed read scope"),
|
||||||
"symlink read escape not rejected: {read_err}"
|
"symlink read escape not rejected: {read_err}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
format!("{read_err}").contains(&outside_target.display().to_string()),
|
||||||
|
"symlink read diagnostic should include resolved target: {read_err}"
|
||||||
|
);
|
||||||
|
|
||||||
// Write through the symlink must be rejected for the same reason.
|
// Write through the symlink must be rejected for the same reason.
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
|
|
@ -120,13 +124,39 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains("outside allowed scope"),
|
msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope"),
|
||||||
"symlink escape not rejected: {msg}"
|
"symlink escape not rejected: {msg}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("add the symlink target"),
|
||||||
|
"symlink escape diagnostic should include remediation: {msg}"
|
||||||
|
);
|
||||||
// Outside file must not have been touched.
|
// Outside file must not have been touched.
|
||||||
assert_eq!(std::fs::read_to_string(&outside_target).unwrap(), "secret");
|
assert_eq!(std::fs::read_to_string(&outside_target).unwrap(), "secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn broken_symlink_reports_target_and_repair_hint() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let (dir, _spill, reg) = setup();
|
||||||
|
let link = dir.path().join("external-project");
|
||||||
|
let target = dir.path().join("missing-target");
|
||||||
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
|
let read = reg.get("Read");
|
||||||
|
let err = read
|
||||||
|
.execute(&json!({ "file_path": link.to_str().unwrap() }).to_string())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(msg.contains("broken symlink"), "{msg}");
|
||||||
|
assert!(msg.contains(&link.display().to_string()), "{msg}");
|
||||||
|
assert!(msg.contains(&target.display().to_string()), "{msg}");
|
||||||
|
assert!(msg.contains("correct relative target"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn empty_file_read_and_edit() {
|
async fn empty_file_read_and_edit() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, _spill, reg) = setup();
|
||||||
|
|
|
||||||
12
docs/file-ref-symlinks.md
Normal file
12
docs/file-ref-symlinks.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# File references and symlinks
|
||||||
|
|
||||||
|
FileRef resolution and file tools follow symlinks only after the resolved target passes the Pod scope check. A symlink placed inside the workspace does not grant access to the target by itself.
|
||||||
|
|
||||||
|
Recommended external-reference workflow:
|
||||||
|
|
||||||
|
- Prefer adding the real external project path, such as a local `ghq` clone, to the Pod read scope when the Pod is started or spawned.
|
||||||
|
- If a workspace symlink is used, the symlink target still must be inside readable scope. For writes, the resolved target must be inside writable scope.
|
||||||
|
- If a relative symlink is broken, recreate it with the correct relative target from the symlink's parent directory, or use an absolute symlink.
|
||||||
|
- Directory traversal tools such as Glob and Grep do not follow symlink directories. Use the resolved target directory directly when it is in read scope.
|
||||||
|
|
||||||
|
This preserves symlink escape safety: access decisions are made on the canonicalized target whenever the target exists, and broken or out-of-scope symlinks are rejected with diagnostics that include the original path and target where possible.
|
||||||
Loading…
Reference in New Issue
Block a user