use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum PathSafetyError {
OutsideWorkspace {
path: PathBuf,
workspace_root: PathBuf,
},
SymlinkTarget {
path: PathBuf,
},
SymlinkInAncestor {
ancestor: PathBuf,
},
Io(std::io::Error),
}
impl std::fmt::Display for PathSafetyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OutsideWorkspace {
path,
workspace_root,
} => write!(
f,
"path '{}' is outside workspace root '{}'",
path.display(),
workspace_root.display(),
),
Self::SymlinkTarget { path } => write!(
f,
"path '{}' is a symlink; sqry refuses to follow symlinks on persistence paths",
path.display(),
),
Self::SymlinkInAncestor { ancestor } => write!(
f,
"ancestor directory '{}' is a symlink; \
all ancestor directories up to the workspace root must be real directories",
ancestor.display(),
),
Self::Io(e) => write!(f, "I/O error during path validation: {e}"),
}
}
}
impl std::error::Error for PathSafetyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for PathSafetyError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
pub fn validate_path_in_workspace(
path: &Path,
workspace_root: &Path,
) -> Result<PathBuf, PathSafetyError> {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
};
let canonical_ws = workspace_root.canonicalize()?;
{
let parent = joined.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"validate_path_in_workspace: path has no parent component: {}",
joined.display()
),
)
})?;
let sub_path = parent.strip_prefix(workspace_root).unwrap_or(parent);
let mut cursor = workspace_root.to_path_buf();
for component in sub_path.components() {
cursor.push(component);
match std::fs::symlink_metadata(&cursor) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(PathSafetyError::SymlinkInAncestor { ancestor: cursor });
}
_ => {}
}
}
}
let parent = joined.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"validate_path_in_workspace: path has no parent component: {}",
joined.display()
),
)
})?;
let canonical_parent = parent.canonicalize().map_err(|e| {
std::io::Error::new(
e.kind(),
format!(
"validate_path_in_workspace: cannot canonicalize parent directory '{}': {e}",
parent.display()
),
)
})?;
let file_name = joined.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"validate_path_in_workspace: path has no file name component: {}",
joined.display()
),
)
})?;
let canonical_path = canonical_parent.join(file_name);
if !canonical_path.starts_with(&canonical_ws) {
return Err(PathSafetyError::OutsideWorkspace {
path: canonical_path,
workspace_root: canonical_ws,
});
}
if let Ok(meta) = std::fs::symlink_metadata(&joined)
&& meta.file_type().is_symlink()
{
return Err(PathSafetyError::SymlinkTarget {
path: canonical_path,
});
}
if let Ok(meta) = std::fs::symlink_metadata(&canonical_path)
&& meta.file_type().is_symlink()
{
return Err(PathSafetyError::SymlinkTarget {
path: canonical_path,
});
}
Ok(canonical_path)
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn tmp_workspace() -> TempDir {
TempDir::new().expect("TempDir::new failed")
}
#[test]
fn happy_path_relative_under_workspace() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
let result = validate_path_in_workspace(Path::new(".sqry/graph/derived.sqry"), ws.path());
assert!(result.is_ok(), "happy path should succeed; got {result:?}");
let canonical = result.unwrap();
assert!(
canonical.starts_with(ws.path().canonicalize().unwrap()),
"canonical path must be inside workspace: {canonical:?}"
);
assert!(
canonical.ends_with("derived.sqry"),
"canonical path must end with 'derived.sqry': {canonical:?}"
);
}
#[test]
fn happy_path_nonexistent_target_is_ok() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
let target = Path::new(".sqry/graph/derived.sqry");
assert!(
!ws.path().join(target).exists(),
"pre-condition: target must not exist"
);
let result = validate_path_in_workspace(target, ws.path());
assert!(
result.is_ok(),
"non-existent target should be allowed; got {result:?}"
);
}
#[test]
fn happy_path_absolute_under_workspace() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
let abs = ws.path().join(".sqry/graph/derived.sqry");
assert!(abs.is_absolute(), "pre-condition: abs must be absolute");
let result = validate_path_in_workspace(&abs, ws.path());
assert!(
result.is_ok(),
"absolute in-workspace path should succeed; got {result:?}"
);
}
#[test]
fn happy_path_existing_regular_file() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
let target = ws.path().join(".sqry/graph/derived.sqry");
fs::write(&target, b"previous data").unwrap();
let result = validate_path_in_workspace(&target, ws.path());
assert!(
result.is_ok(),
"existing regular file should succeed; got {result:?}"
);
}
#[test]
fn rejects_path_outside_workspace() {
let ws = tmp_workspace();
let outside = TempDir::new().unwrap();
let result = validate_path_in_workspace(&outside.path().join("derived.sqry"), ws.path());
match result {
Err(PathSafetyError::OutsideWorkspace { .. }) => {}
other => panic!("expected OutsideWorkspace, got {other:?}"),
}
}
#[test]
fn rejects_dotdot_escape() {
let ws = tmp_workspace();
let result = validate_path_in_workspace(Path::new("../../etc/passwd"), ws.path());
match result {
Err(PathSafetyError::OutsideWorkspace { .. }) => {}
Err(PathSafetyError::Io(_)) => {}
other => panic!("expected OutsideWorkspace or Io, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn rejects_symlink_target() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
let real = ws.path().join(".sqry/graph/real.sqry");
fs::write(&real, b"x").unwrap();
let link = ws.path().join(".sqry/graph/derived.sqry");
std::os::unix::fs::symlink(&real, &link).unwrap();
let result = validate_path_in_workspace(&link, ws.path());
match result {
Err(PathSafetyError::SymlinkTarget { .. }) => {}
other => panic!("expected SymlinkTarget, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn rejects_dangling_symlink_target() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
let link = ws.path().join(".sqry/graph/derived.sqry");
std::os::unix::fs::symlink(ws.path().join("nonexistent"), &link).unwrap();
assert!(
link.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false),
"pre-condition: link must be a symlink"
);
let result = validate_path_in_workspace(&link, ws.path());
match result {
Err(PathSafetyError::SymlinkTarget { .. }) => {}
other => panic!("expected SymlinkTarget for dangling symlink, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn rejects_symlink_in_ancestor() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry_real/graph")).unwrap();
std::os::unix::fs::symlink(ws.path().join(".sqry_real"), ws.path().join(".sqry")).unwrap();
let result = validate_path_in_workspace(Path::new(".sqry/graph/derived.sqry"), ws.path());
match result {
Err(PathSafetyError::SymlinkInAncestor { .. }) => {}
other => panic!("expected SymlinkInAncestor, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn rejects_symlink_intermediate_ancestor() {
let ws = tmp_workspace();
fs::create_dir_all(ws.path().join(".sqry")).unwrap();
fs::create_dir_all(ws.path().join("graph_real")).unwrap();
std::os::unix::fs::symlink(ws.path().join("graph_real"), ws.path().join(".sqry/graph"))
.unwrap();
let result = validate_path_in_workspace(Path::new(".sqry/graph/derived.sqry"), ws.path());
match result {
Err(PathSafetyError::SymlinkInAncestor { .. }) => {}
other => panic!("expected SymlinkInAncestor for intermediate symlink, got {other:?}"),
}
}
#[test]
fn display_outside_workspace_cites_paths() {
let err = PathSafetyError::OutsideWorkspace {
path: PathBuf::from("/tmp/escape/foo.sqry"),
workspace_root: PathBuf::from("/home/user/project"),
};
let msg = err.to_string();
assert!(
msg.contains("/tmp/escape/foo.sqry"),
"Display must cite the offending path; got: {msg}"
);
assert!(
msg.contains("/home/user/project"),
"Display must cite the workspace root; got: {msg}"
);
}
#[test]
fn display_symlink_target_cites_path() {
let err = PathSafetyError::SymlinkTarget {
path: PathBuf::from("/ws/.sqry/graph/derived.sqry"),
};
let msg = err.to_string();
assert!(
msg.contains("/ws/.sqry/graph/derived.sqry"),
"Display must cite the symlink path; got: {msg}"
);
}
#[test]
fn display_symlink_in_ancestor_cites_path() {
let err = PathSafetyError::SymlinkInAncestor {
ancestor: PathBuf::from("/ws/.sqry/graph"),
};
let msg = err.to_string();
assert!(
msg.contains("/ws/.sqry/graph"),
"Display must cite the ancestor path; got: {msg}"
);
}
#[test]
fn display_io_delegates_to_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let err = PathSafetyError::Io(io_err);
let msg = err.to_string();
assert!(!msg.is_empty(), "Display for Io variant must not be empty");
}
#[test]
fn error_source_io_variant_is_some() {
use std::error::Error as _;
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let err = PathSafetyError::Io(io_err);
assert!(
err.source().is_some(),
"Io variant must expose source via std::error::Error::source()"
);
}
#[test]
fn error_source_non_io_variants_are_none() {
use std::error::Error as _;
let outside = PathSafetyError::OutsideWorkspace {
path: PathBuf::from("/a"),
workspace_root: PathBuf::from("/b"),
};
assert!(outside.source().is_none());
let sym_target = PathSafetyError::SymlinkTarget {
path: PathBuf::from("/a"),
};
assert!(sym_target.source().is_none());
let sym_anc = PathSafetyError::SymlinkInAncestor {
ancestor: PathBuf::from("/a"),
};
assert!(sym_anc.source().is_none());
}
#[test]
fn from_io_error_constructs_io_variant() {
let io_err = std::io::Error::other("test");
let safety_err = PathSafetyError::from(io_err);
assert!(
matches!(safety_err, PathSafetyError::Io(_)),
"From<io::Error> must yield the Io variant"
);
}
}