use crate::error::{Result, SofosError};
use crate::tools::ToolExecutor;
use crate::tools::permissions;
use crate::tools::utils::is_absolute_or_tilde;
use std::path::{Component, Path, PathBuf};
fn lexically_normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
out.push(c.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
let last_is_normal = out
.components()
.next_back()
.map(|c| matches!(c, Component::Normal(_)))
.unwrap_or(false);
if last_is_normal {
out.pop();
} else {
out.push(Component::ParentDir.as_os_str());
}
}
}
}
out
}
pub struct ResolvedPath {
pub canonical: std::path::PathBuf,
pub canonical_str: String,
pub is_inside_workspace: bool,
}
impl ToolExecutor {
fn resolve(&self, caller_path: &str, must_exist: bool) -> Result<ResolvedPath> {
let full_path = if is_absolute_or_tilde(caller_path) {
std::path::PathBuf::from(permissions::PermissionManager::expand_tilde_pub(
caller_path,
))
} else {
self.fs_tool.workspace().join(caller_path)
};
let canonical = if must_exist {
std::fs::canonicalize(&full_path)
.map_err(|_| SofosError::FileNotFound(caller_path.to_string()))?
} else {
let mut missing_tail: Vec<std::ffi::OsString> = Vec::new();
let mut cursor = full_path.as_path();
let canonical_anchor = loop {
if cursor.exists() {
break std::fs::canonicalize(cursor).map_err(|e| {
SofosError::ToolExecution(format!("Failed to resolve path: {}", e))
})?;
}
match (cursor.file_name(), cursor.parent()) {
(Some(name), Some(parent)) => {
missing_tail.push(name.to_os_string());
cursor = parent;
}
_ => {
let normalized = lexically_normalize(&full_path);
let is_inside_workspace = normalized.starts_with(self.fs_tool.workspace());
let canonical_str = normalized.to_string_lossy().to_string();
return Ok(ResolvedPath {
canonical: normalized,
canonical_str,
is_inside_workspace,
});
}
}
};
let mut canonical = canonical_anchor;
for name in missing_tail.iter().rev() {
canonical.push(name);
}
canonical
};
let is_inside_workspace = canonical.starts_with(self.fs_tool.workspace());
let canonical_str = canonical.to_string_lossy().to_string();
Ok(ResolvedPath {
canonical,
canonical_str,
is_inside_workspace,
})
}
pub(super) fn resolve_existing(&self, caller_path: &str) -> Result<ResolvedPath> {
self.resolve(caller_path, true)
}
pub(super) fn resolve_for_write(&self, caller_path: &str) -> Result<ResolvedPath> {
self.resolve(caller_path, false)
}
}
#[cfg(test)]
mod tests {
use super::lexically_normalize;
use std::path::PathBuf;
#[test]
fn lexically_normalize_collapses_current_dir() {
assert_eq!(
lexically_normalize(&PathBuf::from("/tmp/./workspace/./file")),
PathBuf::from("/tmp/workspace/file")
);
}
#[test]
fn lexically_normalize_collapses_parent_dir() {
assert_eq!(
lexically_normalize(&PathBuf::from("/tmp/workspace/foo/../bar")),
PathBuf::from("/tmp/workspace/bar")
);
}
#[test]
fn lexically_normalize_escapes_workspace_via_double_dot() {
let workspace = PathBuf::from("/home/user/project");
let joined = workspace.join("../../etc/passwd");
let normalized = lexically_normalize(&joined);
assert_eq!(normalized, PathBuf::from("/home/etc/passwd"));
assert!(!normalized.starts_with(&workspace));
}
#[test]
fn lexically_normalize_keeps_leading_parent_when_over_popping() {
assert_eq!(
lexically_normalize(&PathBuf::from("../../etc")),
PathBuf::from("../../etc")
);
}
}