use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
pub const SECRET_EXT: &str = "age";
pub fn validate(path: &str) -> Result<()> {
if path.is_empty() {
return Err(Error::InvalidPath("path must not be empty".into()));
}
if path.len() > 1024 {
return Err(Error::InvalidPath("path is too long (>1024)".into()));
}
if path.starts_with('/') || path.ends_with('/') {
return Err(Error::InvalidPath(
"path must not start or end with `/`".into(),
));
}
for segment in path.split('/') {
validate_segment(segment)?;
}
Ok(())
}
fn validate_segment(segment: &str) -> Result<()> {
if segment.is_empty() {
return Err(Error::InvalidPath(
"path must not contain empty segments (consecutive `/`)".into(),
));
}
if segment == "." || segment == ".." {
return Err(Error::InvalidPath(
"path may not contain `.` or `..` segments".into(),
));
}
if segment.starts_with('.') || segment.ends_with('.') {
return Err(Error::InvalidPath(
"segments may not start or end with `.`".into(),
));
}
if is_windows_reserved(segment) {
return Err(Error::InvalidPath(format!(
"`{segment}` is a reserved Windows filename"
)));
}
for ch in segment.chars() {
if !is_allowed_char(ch) {
return Err(Error::InvalidPath(format!(
"illegal character `{ch}` in path"
)));
}
}
Ok(())
}
const fn is_allowed_char(ch: char) -> bool {
matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.')
}
fn is_windows_reserved(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
let stem = upper.split_once('.').map_or(upper.as_str(), |(s, _)| s);
matches!(
stem,
"CON"
| "PRN"
| "AUX"
| "NUL"
| "COM1"
| "COM2"
| "COM3"
| "COM4"
| "COM5"
| "COM6"
| "COM7"
| "COM8"
| "COM9"
| "LPT1"
| "LPT2"
| "LPT3"
| "LPT4"
| "LPT5"
| "LPT6"
| "LPT7"
| "LPT8"
| "LPT9"
)
}
#[must_use]
pub fn to_file(store_root: &Path, logical: &str) -> PathBuf {
let mut buf = store_root.to_path_buf();
for segment in logical.split('/') {
buf.push(segment);
}
buf.set_extension(SECRET_EXT);
buf
}
#[must_use]
pub fn from_file(store_root: &Path, file: &Path) -> Option<String> {
let rel = file.strip_prefix(store_root).ok()?;
if rel.extension().and_then(|s| s.to_str()) != Some(SECRET_EXT) {
return None;
}
let with_ext = rel.to_str()?;
let logical = with_ext.strip_suffix(".age")?;
let normalised = logical.replace('\\', "/");
if normalised.is_empty() || normalised.contains("..") {
return None;
}
Some(normalised)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_path() {
assert!(validate("github/token").is_ok());
assert!(validate("aws/prod/access-key").is_ok());
assert!(validate("single").is_ok());
assert!(validate("a.b/c_d/e-f").is_ok());
}
#[test]
fn rejects_empty_and_separator_edges() {
assert!(validate("").is_err());
assert!(validate("/foo").is_err());
assert!(validate("foo/").is_err());
assert!(validate("foo//bar").is_err());
}
#[test]
fn rejects_traversal() {
assert!(validate("..").is_err());
assert!(validate("foo/../bar").is_err());
assert!(validate("./foo").is_err());
}
#[test]
fn rejects_bad_chars() {
assert!(validate("foo bar").is_err());
assert!(validate("foo\\bar").is_err());
assert!(validate("foo:bar").is_err());
assert!(validate("中文").is_err());
}
#[test]
fn rejects_windows_reserved() {
assert!(validate("con").is_err());
assert!(validate("COM1").is_err());
assert!(validate("foo/nul").is_err());
}
#[test]
fn roundtrip_to_file() {
let root = Path::new("/tmp/ks");
let p = to_file(root, "github/token");
assert_eq!(p, Path::new("/tmp/ks/github/token.age"));
let logical = from_file(root, &p).expect("should map back");
assert_eq!(logical, "github/token");
}
}