use std::path::{Component, Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use crate::{Error, Result};
static GROUP_FOLDER_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$").expect("static regex"));
const RESERVED_FOLDERS: &[&str] = &["global", "errors"];
pub fn is_valid_group_folder(folder: &str) -> bool {
!folder.is_empty()
&& folder == folder.trim()
&& GROUP_FOLDER_PATTERN.is_match(folder)
&& !folder.contains("..")
&& !RESERVED_FOLDERS
.iter()
.any(|r| r.eq_ignore_ascii_case(folder))
}
pub fn assert_valid_group_folder(folder: &str) -> Result<()> {
if is_valid_group_folder(folder) {
Ok(())
} else {
Err(Error::security(format!("invalid group folder {folder:?}")))
}
}
pub fn normalize_lexically(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn ensure_within(base: &Path, candidate: &Path) -> Result<PathBuf> {
let base_n = normalize_lexically(base);
let cand_n = normalize_lexically(candidate);
if cand_n.starts_with(&base_n) {
Ok(cand_n)
} else {
Err(Error::security(format!(
"path {} escapes base directory {}",
candidate.display(),
base.display()
)))
}
}
fn resolve_under(base: &Path, key: &str) -> Result<PathBuf> {
assert_valid_group_folder(key)?;
ensure_within(base, &base.join(key))
}
pub fn group_folder_path(groups_dir: &Path, folder: &str) -> Result<PathBuf> {
resolve_under(groups_dir, folder)
}
pub fn group_ipc_path(data_dir: &Path, ipc_key: &str) -> Result<PathBuf> {
resolve_under(&data_dir.join("ipc"), ipc_key)
}
pub fn group_sessions_path(data_dir: &Path, session_key: &str) -> Result<PathBuf> {
resolve_under(&data_dir.join("sessions"), session_key)
}
pub fn expand_home(p: &str) -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
if p == "~" {
PathBuf::from(home)
} else if let Some(rest) = p.strip_prefix("~/") {
Path::new(&home).join(rest)
} else {
PathBuf::from(p)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folder_validation() {
assert!(is_valid_group_folder("main"));
assert!(is_valid_group_folder("copilot-u-42"));
assert!(!is_valid_group_folder(""));
assert!(!is_valid_group_folder("../etc"));
assert!(!is_valid_group_folder("a/b"));
assert!(!is_valid_group_folder("Global"));
assert!(!is_valid_group_folder(" main"));
assert!(!is_valid_group_folder("-lead"));
assert!(!is_valid_group_folder(&"a".repeat(65)));
}
#[test]
fn resolution_stays_in_base() {
let base = Path::new("/srv/talon/groups");
assert_eq!(
group_folder_path(base, "main").unwrap(),
PathBuf::from("/srv/talon/groups/main")
);
assert!(group_folder_path(base, "..").is_err());
assert!(ensure_within(base, Path::new("/srv/talon/groups/../x")).is_err());
}
#[test]
fn ipc_and_session_paths() {
let data = Path::new("/d");
assert_eq!(
group_ipc_path(data, "g").unwrap(),
PathBuf::from("/d/ipc/g")
);
assert_eq!(
group_sessions_path(data, "g").unwrap(),
PathBuf::from("/d/sessions/g")
);
}
}