use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};
use crate::tool::ToolError;
#[derive(Clone, Copy)]
enum PathResolutionCode {
InvalidPath,
FileNotFound,
}
impl PathResolutionCode {
const fn as_str(self) -> &'static str {
match self {
Self::InvalidPath => "INVALID_PATH",
Self::FileNotFound => "FILE_NOT_FOUND",
}
}
}
fn path_tool_error(code: PathResolutionCode, message: impl Into<String>) -> ToolError {
ToolError {
code: code.as_str().to_string(),
message: message.into(),
}
}
pub(crate) fn map_io_error(err: std::io::Error, context: &str) -> ToolError {
let code = match err.kind() {
ErrorKind::NotFound => "FILE_NOT_FOUND",
ErrorKind::PermissionDenied => "PERMISSION_DENIED",
ErrorKind::AlreadyExists => "FILE_ALREADY_EXISTS",
ErrorKind::DirectoryNotEmpty => "DIRECTORY_NOT_EMPTY",
ErrorKind::InvalidInput | ErrorKind::InvalidData => "INVALID_PATH",
#[cfg(target_os = "windows")]
ErrorKind::InvalidFilename => "INVALID_PATH",
_ => "INVALID_PATH",
};
let prefix = if context.is_empty() {
String::new()
} else {
format!("{context}: ")
};
ToolError {
code: code.to_string(),
message: format!("{prefix}{err}"),
}
}
pub(crate) fn normalize_path(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
out.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
let _ = out.pop();
}
Component::Normal(part) => out.push(part),
}
}
out
}
pub(crate) fn is_descendant(root: &Path, path: &Path) -> bool {
let r: Vec<_> = root.components().collect();
let p: Vec<_> = path.components().collect();
if p.len() < r.len() {
return false;
}
r.iter().zip(p.iter()).all(|(a, b)| a == b)
}
pub(crate) fn ensure_under_root(root: &Path, resolved: &Path) -> Result<(), ToolError> {
if is_descendant(root, resolved) {
Ok(())
} else {
Err(path_tool_error(
PathResolutionCode::InvalidPath,
"path escapes working root (sandbox)",
))
}
}
pub(crate) fn combine_and_normalize(base: &Path, user: &Path) -> PathBuf {
let combined = if user.is_absolute() {
user.to_path_buf()
} else {
base.join(user)
};
normalize_path(&combined)
}
pub(crate) fn resolve_with_existing_prefix(logical: &Path) -> Result<PathBuf, ToolError> {
if logical.exists() {
return logical
.canonicalize()
.map_err(|e| map_io_error(e, "canonicalize"));
}
let mut cur = logical.to_path_buf();
let mut tail_parts: Vec<std::ffi::OsString> = Vec::new();
while !cur.exists() {
let Some(name) = cur.file_name() else {
return Err(path_tool_error(
PathResolutionCode::InvalidPath,
"invalid path (no file name component)",
));
};
tail_parts.push(name.to_os_string());
if !cur.pop() {
break;
}
}
let base = if cur.as_os_str().is_empty() {
std::env::current_dir().map_err(|e| map_io_error(e, "cwd"))?
} else {
cur.canonicalize()
.map_err(|e| map_io_error(e, "canonicalize"))?
};
let mut out = base;
for name in tail_parts.into_iter().rev() {
out.push(name);
}
Ok(out)
}
pub(crate) fn resolve_sandboxed(
root_canonical: &Path,
logical: &Path,
) -> Result<PathBuf, ToolError> {
if logical.exists() {
let c = logical
.canonicalize()
.map_err(|e| map_io_error(e, "canonicalize"))?;
ensure_under_root(root_canonical, &c)?;
return Ok(c);
}
let mut cur = logical.to_path_buf();
let mut tail_parts: Vec<std::ffi::OsString> = Vec::new();
while !cur.exists() {
let Some(name) = cur.file_name() else {
return Err(path_tool_error(
PathResolutionCode::InvalidPath,
"invalid path (no file name component)",
));
};
tail_parts.push(name.to_os_string());
if !cur.pop() {
return Err(path_tool_error(
PathResolutionCode::FileNotFound,
"path has no existing parent under sandbox",
));
}
}
let base = cur
.canonicalize()
.map_err(|e| map_io_error(e, "canonicalize"))?;
ensure_under_root(root_canonical, &base)?;
let mut out = base;
for name in tail_parts.into_iter().rev() {
out.push(name);
}
ensure_under_root(root_canonical, &out)?;
Ok(out)
}
pub(crate) fn resolve_against_workspace_root(
root_canonical: &Path,
allow_outside_root: bool,
user: &str,
) -> Result<PathBuf, ToolError> {
let s = user.trim();
if s.is_empty() {
return Err(path_tool_error(
PathResolutionCode::InvalidPath,
"path is empty",
));
}
let user_path = Path::new(s);
let logical = combine_and_normalize(root_canonical, user_path);
if allow_outside_root {
if logical.exists() {
return logical
.canonicalize()
.map_err(|e| map_io_error(e, "canonicalize"));
}
return resolve_with_existing_prefix(&logical);
}
resolve_sandboxed(root_canonical, &logical)
}