Skip to main content

thoughts_tool/utils/
validation.rs

1use anyhow::Result;
2use anyhow::bail;
3use std::path::Component;
4use std::path::Path;
5
6/// Validate a simple filename: no directories, no traversal, not absolute.
7/// Allows [A-Za-z0-9._-] only, must not be empty.
8pub fn validate_simple_filename(filename: &str) -> Result<()> {
9    if filename.trim().is_empty() {
10        bail!("Filename cannot be empty");
11    }
12
13    // Parse as path and check components
14    let p = Path::new(filename);
15    let mut comps = p.components();
16
17    // Reject absolute paths
18    if matches!(
19        comps.next(),
20        Some(Component::RootDir | Component::Prefix(_))
21    ) {
22        bail!("Absolute paths are not allowed");
23    }
24
25    // Must be single component (no directories)
26    if p.components().count() != 1 {
27        bail!("Filename must not contain directories");
28    }
29
30    // Reject special names
31    if filename == "." || filename == ".." {
32        bail!("Invalid filename");
33    }
34
35    // Restrict to safe character set
36    let ok = filename
37        .chars()
38        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
39    if !ok {
40        bail!("Filename contains invalid characters (allowed: A-Z a-z 0-9 . _ -)");
41    }
42
43    Ok(())
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    #[test]
51    fn test_validate_simple_filename_ok() {
52        for f in ["a.md", "plan-01.md", "notes_v2.md", "R1.TOC"] {
53            assert!(validate_simple_filename(f).is_ok(), "{f}");
54        }
55    }
56
57    #[test]
58    fn test_validate_simple_filename_bad() {
59        for f in [
60            "../x.md",
61            "/abs.md",
62            "a/b.md",
63            " ",
64            "",
65            ".",
66            "..",
67            "name with space.md",
68        ] {
69            assert!(validate_simple_filename(f).is_err(), "{f}");
70        }
71    }
72}