thoughts_tool/utils/
validation.rs

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