Skip to main content

krypton/
sanitize.rs

1//! Entry-name validation and sanitization.
2//!
3//! Encrypted containers store filenames. A malicious container (or a hostile
4//! peer) can embed names like `../../.ssh/id_rsa`; anything reconstructed
5//! from stored names and joined onto a user-supplied destination must pass
6//! through [`sanitize_entry_name`] first.
7
8use crate::error::{Error, Result};
9
10/// Maximum accepted entry-name length in bytes.
11pub const MAX_NAME_LEN: usize = 255;
12
13/// Validates a name supplied for a *new* vault entry.
14///
15/// Rules: non-empty, at most [`MAX_NAME_LEN`] bytes, no NUL, no `/` or `\`
16/// separators, no `.`/`..` components, not absolute.
17pub fn validate_new_name(name: &str) -> Result<()> {
18    if name.is_empty() || name.len() > MAX_NAME_LEN {
19        return Err(Error::invalid_name(name));
20    }
21    if name.contains('\0') {
22        return Err(Error::invalid_name(name));
23    }
24    let path = std::path::Path::new(name);
25    if path.is_absolute() {
26        return Err(Error::invalid_name(name));
27    }
28    for comp in path.components() {
29        match comp {
30            std::path::Component::Normal(_) => {}
31            _ => return Err(Error::invalid_name(name)),
32        }
33    }
34    // Backslash is a separator on Windows and confusing everywhere else.
35    if name.contains('\\') {
36        return Err(Error::invalid_name(name));
37    }
38    Ok(())
39}
40
41/// Makes an untrusted, previously-stored name safe to join onto a filesystem
42/// path.
43///
44/// This is defense in depth: names recovered from authenticated storage are
45/// normally already valid, but extraction must stay safe even when the vault
46/// was produced by a hostile implementation. Any name that cannot be made
47/// safe is rejected rather than mangled into something silently different.
48pub fn sanitize_stored_name(name: &str) -> Result<String> {
49    if name.is_empty() || name.len() > MAX_NAME_LEN {
50        return Err(Error::invalid_name(name));
51    }
52    validate_new_name(name)?;
53    Ok(name.to_string())
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn accepts_normal_names() {
62        assert!(validate_new_name("report.pdf").is_ok());
63        assert!(validate_new_name("photos").is_ok());
64        assert!(validate_new_name("photos/vacation/img.png").is_ok());
65        assert!(validate_new_name("ünïcødé.txt").is_ok());
66    }
67
68    #[test]
69    fn rejects_traversal_and_specials() {
70        assert!(validate_new_name("").is_err());
71        assert!(validate_new_name("../evil").is_err());
72        assert!(validate_new_name("a/../b").is_err());
73        assert!(validate_new_name("/abs").is_err());
74        assert!(validate_new_name("C:\\\\x").is_err());
75        assert!(validate_new_name("a\\b").is_err());
76        assert!(validate_new_name("nul\0").is_err());
77        assert!(validate_new_name(&"x".repeat(256)).is_err());
78    }
79
80    #[test]
81    fn sanitize_passthrough_valid() {
82        assert_eq!(sanitize_stored_name("ok.txt").unwrap(), "ok.txt");
83    }
84
85    #[test]
86    fn sanitize_rejects_traversal() {
87        assert!(sanitize_stored_name("../../etc/passwd").is_err());
88    }
89}