use std::path::{Component, Path, PathBuf};
use anyhow::Context;
fn validate_and_canonicalize(path: &str) -> anyhow::Result<PathBuf> {
if path.is_empty() {
anyhow::bail!("empty path supplied to semantic input validator");
}
let raw = PathBuf::from(path);
if raw
.components()
.any(|component| matches!(component, Component::ParentDir))
{
anyhow::bail!("parent-dir traversal in semantic input path: {path}");
}
raw.canonicalize()
.with_context(|| format!("canonicalize semantic input path {path}"))
}
pub(crate) fn read_validated_semantic_input(path: &str) -> anyhow::Result<String> {
let canonical = validate_and_canonicalize(path)?;
std::fs::read_to_string(&canonical)
.with_context(|| format!("read semantic input {}", canonical.display()))
}
pub(crate) fn try_read_validated_semantic_input(path: &str) -> anyhow::Result<Option<String>> {
if path.is_empty() {
anyhow::bail!("empty path supplied to semantic input validator");
}
let raw = PathBuf::from(path);
if raw
.components()
.any(|component| matches!(component, Component::ParentDir))
{
anyhow::bail!("parent-dir traversal in semantic input path: {path}");
}
let canonical = match raw.canonicalize() {
Ok(p) => p,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(anyhow::Error::from(err)
.context(format!("canonicalize semantic input path {path}")));
}
};
let content = std::fs::read_to_string(&canonical)
.with_context(|| format!("read semantic input {}", canonical.display()))?;
Ok(Some(content))
}
pub(crate) fn list_idiom_override_files(override_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let canonical_root = override_dir
.canonicalize()
.with_context(|| format!("canonicalize idiom override dir {}", override_dir.display()))?;
let mut paths = Vec::new();
for entry in std::fs::read_dir(&canonical_root)
.with_context(|| format!("read_dir {}", canonical_root.display()))?
{
let entry = entry?;
let raw = entry.path();
let canonical = raw
.canonicalize()
.with_context(|| format!("canonicalize idiom override entry {}", raw.display()))?;
if !canonical.starts_with(&canonical_root) {
anyhow::bail!(
"idiom override entry escapes override dir: {} -> {}",
raw.display(),
canonical.display()
);
}
if canonical.extension().and_then(|s| s.to_str()) == Some("toml") {
paths.push(canonical);
}
}
paths.sort();
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty_path() {
let err = read_validated_semantic_input("").unwrap_err();
assert!(err.to_string().contains("empty path"));
let err = try_read_validated_semantic_input("").unwrap_err();
assert!(err.to_string().contains("empty path"));
}
#[test]
fn rejects_parent_traversal() {
let err = read_validated_semantic_input("../etc/passwd").unwrap_err();
assert!(err.to_string().contains("parent-dir traversal"));
let err = try_read_validated_semantic_input("foo/../bar").unwrap_err();
assert!(err.to_string().contains("parent-dir traversal"));
}
#[test]
fn try_read_returns_none_for_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist.sh");
let result = try_read_validated_semantic_input(&missing.to_string_lossy()).unwrap();
assert!(result.is_none());
}
#[test]
fn read_returns_content_for_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("hello.sh");
std::fs::write(&path, "echo hi\n").unwrap();
let content = read_validated_semantic_input(&path.to_string_lossy()).unwrap();
assert_eq!(content, "echo hi\n");
}
#[test]
fn list_idiom_overrides_returns_only_toml() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("a.toml"), "").unwrap();
std::fs::write(tmp.path().join("b.toml"), "").unwrap();
std::fs::write(tmp.path().join("c.txt"), "").unwrap();
let result = list_idiom_override_files(tmp.path()).unwrap();
assert_eq!(result.len(), 2);
assert!(
result
.iter()
.all(|p| p.extension().and_then(|s| s.to_str()) == Some("toml"))
);
}
#[test]
fn list_idiom_overrides_rejects_symlink_escape() {
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("evil.toml"), "").unwrap();
let link = tmp.path().join("link.toml");
#[cfg(unix)]
{
std::os::unix::fs::symlink(outside.path().join("evil.toml"), &link).unwrap();
let err = list_idiom_override_files(tmp.path()).unwrap_err();
assert!(
err.to_string().contains("escapes override dir"),
"expected escape error, got: {err}"
);
}
#[cfg(not(unix))]
{
let _ = link;
}
}
}