use glob::glob;
use std::fs;
use std::path::{Component, Path, PathBuf};
use super::LoaderError;
fn contains_traversal(pattern: &str) -> bool {
let path = Path::new(pattern);
path.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
}
fn is_within_base_dir(base_dir: &Path, path: &Path) -> bool {
path.strip_prefix(base_dir).is_ok()
}
fn has_symlink_component(base_dir: &Path, path: &Path) -> std::io::Result<bool> {
let Ok(relative) = path.strip_prefix(base_dir) else {
return Ok(false);
};
let mut current = base_dir.to_path_buf();
for component in relative.components() {
current.push(component.as_os_str());
if fs::symlink_metadata(¤t)?.file_type().is_symlink() {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn discover_files(
base_dir: &Path,
patterns: &[String],
) -> Result<Vec<PathBuf>, LoaderError> {
if !base_dir.exists() {
return Err(LoaderError::directory_not_found(base_dir));
}
if !base_dir.is_dir() {
return Err(LoaderError::directory_not_found(base_dir));
}
let mut files = Vec::new();
for pattern in patterns {
if contains_traversal(pattern) {
tracing::warn!(
pattern = %pattern,
"Rejecting pattern with directory traversal"
);
continue;
}
let full_pattern = base_dir.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
tracing::debug!(pattern = %pattern_str, "Searching for files");
for entry in glob(&pattern_str)? {
let path = entry?;
if !is_within_base_dir(base_dir, &path) {
tracing::warn!(
path = %path.display(),
base_dir = %base_dir.display(),
"Skipping match outside base directory"
);
continue;
}
match has_symlink_component(base_dir, &path) {
Ok(true) => {
tracing::debug!(path = %path.display(), "Skipping symlinked path");
continue;
}
Err(error) => {
tracing::warn!(
path = %path.display(),
error = %error,
"Skipping path with unreadable metadata"
);
continue;
}
Ok(false) => {}
}
if is_in_profile_dir(base_dir, &path) {
tracing::debug!(path = %path.display(), "Skipping profile file");
continue;
}
files.push(path);
}
}
if files.is_empty() {
tracing::warn!(
base_dir = %base_dir.display(),
patterns = ?patterns,
"No .pasta files found"
);
}
Ok(files)
}
fn is_in_profile_dir(base_dir: &Path, path: &Path) -> bool {
let profile_dir = base_dir.join("profile");
path.starts_with(&profile_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_structure(temp: &TempDir) -> PathBuf {
let base = temp.path();
fs::create_dir_all(base.join("dic/greeting")).unwrap();
fs::create_dir_all(base.join("dic/conversation")).unwrap();
fs::write(base.join("dic/greeting/hello.pasta"), "# hello").unwrap();
fs::write(base.join("dic/greeting/goodbye.pasta"), "# goodbye").unwrap();
fs::write(base.join("dic/conversation/chat.pasta"), "# chat").unwrap();
fs::write(base.join("dic/root.pasta"), "# root").unwrap();
fs::create_dir_all(base.join("profile/pasta/cache/lua")).unwrap();
fs::write(
base.join("profile/pasta/cache/lua/cached.pasta"),
"# cached",
)
.unwrap();
base.to_path_buf()
}
#[test]
fn test_discover_default_pattern() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["dic/*/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
assert_eq!(files.len(), 3);
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(file_names.contains(&"hello.pasta".to_string()));
assert!(file_names.contains(&"goodbye.pasta".to_string()));
assert!(file_names.contains(&"chat.pasta".to_string()));
}
#[test]
fn test_discover_excludes_root_dic() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["dic/*/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(!file_names.contains(&"root.pasta".to_string()));
}
#[test]
fn test_discover_excludes_profile() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(!file_names.contains(&"cached.pasta".to_string()));
}
#[test]
fn test_discover_nonexistent_directory() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("nonexistent");
let patterns = vec!["dic/*/*.pasta".to_string()];
let result = discover_files(&nonexistent, &patterns);
assert!(result.is_err());
match result {
Err(LoaderError::DirectoryNotFound(_)) => {}
_ => panic!("Expected DirectoryNotFound error"),
}
}
#[test]
fn test_discover_empty_directory() {
let temp = TempDir::new().unwrap();
let base_dir = temp.path();
fs::create_dir_all(base_dir.join("dic/empty")).unwrap();
let patterns = vec!["dic/*/*.pasta".to_string()];
let files = discover_files(base_dir, &patterns).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_discover_multiple_patterns() {
let temp = TempDir::new().unwrap();
let base_dir = temp.path();
fs::create_dir_all(base_dir.join("dic/sub")).unwrap();
fs::create_dir_all(base_dir.join("extra")).unwrap();
fs::write(base_dir.join("dic/sub/a.pasta"), "# a").unwrap();
fs::write(base_dir.join("extra/b.pasta"), "# b").unwrap();
let patterns = vec!["dic/*/*.pasta".to_string(), "extra/*.pasta".to_string()];
let files = discover_files(base_dir, &patterns).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn test_discover_rejects_parent_dir_traversal() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["../../../etc/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_discover_rejects_traversal_preserves_valid() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["../secret/*.pasta".to_string(), "dic/*/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
assert_eq!(files.len(), 3);
}
#[test]
fn test_contains_traversal() {
assert!(contains_traversal("../foo/*.pasta"));
assert!(contains_traversal("foo/../../bar/*.pasta"));
assert!(!contains_traversal("dic/*/*.pasta"));
assert!(!contains_traversal("**/*.pasta"));
assert!(!contains_traversal("extra/*.pasta"));
}
#[test]
fn test_contains_traversal_absolute_paths() {
assert!(contains_traversal("/etc/*.pasta"));
#[cfg(windows)]
assert!(contains_traversal(r"C:\secret\*.pasta"));
}
#[test]
fn test_discover_base_dir_is_file() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("not_a_dir");
fs::write(&file_path, "plain file").unwrap();
let patterns = vec!["dic/*/*.pasta".to_string()];
let result = discover_files(&file_path, &patterns);
match result {
Err(LoaderError::DirectoryNotFound(path)) => {
assert_eq!(path, file_path);
}
other => panic!("Expected DirectoryNotFound, got: {:?}", other),
}
}
#[test]
fn test_discover_invalid_glob_pattern() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["dic/[/*.pasta".to_string()];
let result = discover_files(&base_dir, &patterns);
assert!(matches!(result, Err(LoaderError::GlobPattern(_))));
}
#[test]
fn test_discover_rejects_absolute_pattern() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["/etc/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_discover_profile_prefix_dir_not_excluded() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
fs::create_dir_all(base_dir.join("profile2/inner")).unwrap();
fs::write(base_dir.join("profile2/inner/kept.pasta"), "# kept").unwrap();
let patterns = vec!["**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(file_names.contains(&"kept.pasta".to_string()));
assert!(!file_names.contains(&"cached.pasta".to_string()));
}
#[test]
fn test_is_within_base_dir() {
let temp = TempDir::new().unwrap();
let base_dir = temp.path().join("base");
let child = base_dir.join("dic/test.pasta");
let outside = temp.path().join("outside/test.pasta");
assert!(is_within_base_dir(&base_dir, &child));
assert!(!is_within_base_dir(&base_dir, &outside));
}
#[cfg(unix)]
#[test]
fn test_discover_skips_symlinked_file() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let external = temp.path().join("external.pasta");
fs::write(&external, "# external").unwrap();
unix_fs::symlink(&external, base_dir.join("dic/greeting/link.pasta")).unwrap();
let patterns = vec!["dic/*/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(files.len(), 3);
assert!(!file_names.contains(&"link.pasta".to_string()));
}
#[cfg(windows)]
#[test]
fn test_discover_skips_junction_directory() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let external_dir = temp.path().join("external");
fs::create_dir_all(&external_dir).unwrap();
fs::write(external_dir.join("secret.pasta"), "# secret").unwrap();
let junction = base_dir.join("dic").join("linked");
let status = std::process::Command::new("cmd")
.arg("/C")
.arg("mklink")
.arg("/J")
.arg(&junction)
.arg(&external_dir)
.status()
.expect("failed to spawn cmd for mklink");
assert!(status.success(), "mklink /J failed");
let patterns = vec!["dic/*/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(files.len(), 3);
assert!(!file_names.contains(&"secret.pasta".to_string()));
}
#[test]
fn test_discover_recursive_includes_flat_root() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["dic/**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(
file_names.contains(&"root.pasta".to_string()),
"recursive pattern must include flat dic/root.pasta, got {file_names:?}"
);
}
#[test]
fn test_discover_recursive_includes_one_level_nested() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["dic/**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(file_names.contains(&"hello.pasta".to_string()));
assert!(file_names.contains(&"goodbye.pasta".to_string()));
assert!(file_names.contains(&"chat.pasta".to_string()));
}
#[test]
fn test_discover_recursive_includes_multi_level_nested() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
fs::create_dir_all(base_dir.join("dic/a/b")).unwrap();
fs::write(base_dir.join("dic/a/b/c.pasta"), "# deep").unwrap();
let patterns = vec!["dic/**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(
file_names.contains(&"c.pasta".to_string()),
"recursive pattern must include multi-level dic/a/b/c.pasta, got {file_names:?}"
);
let one_level = discover_files(&base_dir, &["dic/*/*.pasta".to_string()]).unwrap();
let one_level_names: Vec<_> = one_level
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(!one_level_names.contains(&"c.pasta".to_string()));
}
#[test]
fn test_discover_recursive_default_full_set() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec!["dic/**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let mut file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
file_names.sort();
assert_eq!(
file_names,
vec![
"chat.pasta".to_string(),
"goodbye.pasta".to_string(),
"hello.pasta".to_string(),
"root.pasta".to_string(),
],
"recursive default must additively include flat + nested files"
);
}
#[test]
fn test_discover_recursive_excludes_profile() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
fs::create_dir_all(base_dir.join("profile/nested")).unwrap();
fs::write(base_dir.join("profile/nested/secret.pasta"), "# secret").unwrap();
let patterns = vec!["**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(!file_names.contains(&"secret.pasta".to_string()));
assert!(!file_names.contains(&"cached.pasta".to_string()));
assert!(file_names.contains(&"hello.pasta".to_string()));
assert!(file_names.contains(&"root.pasta".to_string()));
}
#[test]
fn test_discover_recursive_rejects_traversal_preserves_valid() {
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let patterns = vec![
"../secret/**/*.pasta".to_string(),
"dic/**/*.pasta".to_string(),
];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(files.len(), 4);
assert!(file_names.contains(&"root.pasta".to_string()));
assert!(file_names.contains(&"hello.pasta".to_string()));
}
#[cfg(unix)]
#[test]
fn test_discover_recursive_skips_symlinked_file() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let external = temp.path().join("external.pasta");
fs::write(&external, "# external").unwrap();
unix_fs::symlink(&external, base_dir.join("dic/greeting/link.pasta")).unwrap();
let patterns = vec!["dic/**/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(!file_names.contains(&"link.pasta".to_string()));
assert!(file_names.contains(&"root.pasta".to_string()));
assert!(file_names.contains(&"hello.pasta".to_string()));
}
#[cfg(unix)]
#[test]
fn test_discover_skips_symlinked_directory() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().unwrap();
let base_dir = create_test_structure(&temp);
let external_dir = temp.path().join("external");
fs::create_dir_all(&external_dir).unwrap();
fs::write(external_dir.join("secret.pasta"), "# secret").unwrap();
unix_fs::symlink(&external_dir, base_dir.join("dic/linked")).unwrap();
let patterns = vec!["dic/*/*.pasta".to_string()];
let files = discover_files(&base_dir, &patterns).unwrap();
let file_names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(files.len(), 3);
assert!(!file_names.contains(&"secret.pasta".to_string()));
}
}