Skip to main content

tanzim_validate/
path.rs

1use crate::error::{Error, ErrorKind};
2use crate::{Meta, Validator};
3use tanzim_value::{Value, ValueType};
4
5/// (`path` feature) The kind of filesystem entry a [`Path`] must point at.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum PathKind {
8    Dir,
9    File,
10    Symlink,
11}
12
13/// (`path` feature) Accepts a filesystem path string.
14///
15/// Format checks (absolute/relative, extension) never touch the filesystem. The
16/// existence, kind, and permission checks do, and only when explicitly requested.
17/// `readable`/`writable` consult OS permission flags where available; where the OS
18/// exposes no such flag the check is a no-op that accepts.
19#[derive(Debug, Clone, Default)]
20pub struct Path {
21    meta: Meta,
22    absolute: bool,
23    relative: bool,
24    extensions: Vec<String>,
25    must_exist: bool,
26    kind: Option<PathKind>,
27    readable: bool,
28    writable: bool,
29}
30
31impl Path {
32    /// Attach human-facing metadata (name, description, examples, default, output conversion).
33    pub fn with_meta(mut self, meta: Meta) -> Self {
34        self.meta = meta;
35        self
36    }
37
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    pub fn absolute(mut self) -> Self {
43        self.absolute = true;
44        self.relative = false;
45        self
46    }
47
48    pub fn relative(mut self) -> Self {
49        self.relative = true;
50        self.absolute = false;
51        self
52    }
53
54    /// Require the path to end in one of the allowed extensions (compared case-insensitively).
55    pub fn extension(mut self, extension: impl Into<String>) -> Self {
56        self.extensions.push(extension.into());
57        self
58    }
59
60    /// Require the path to exist on the filesystem.
61    pub fn must_exist(mut self) -> Self {
62        self.must_exist = true;
63        self
64    }
65
66    /// Require the path to point at the given kind of entry (implies existence).
67    pub fn kind(mut self, kind: PathKind) -> Self {
68        self.kind = Some(kind);
69        self
70    }
71
72    /// Require the path to be readable (implies existence).
73    pub fn readable(mut self) -> Self {
74        self.readable = true;
75        self
76    }
77
78    /// Require the path to be writable (implies existence).
79    pub fn writable(mut self) -> Self {
80        self.writable = true;
81        self
82    }
83
84    fn touches_filesystem(&self) -> bool {
85        self.must_exist || self.kind.is_some() || self.readable || self.writable
86    }
87}
88
89/// Whether the file mode grants read permission. On non-unix targets there is no such
90/// flag, so this accepts (returns `true`).
91#[cfg(unix)]
92fn is_readable(metadata: &std::fs::Metadata) -> bool {
93    use std::os::unix::fs::PermissionsExt;
94    metadata.permissions().mode() & 0o444 != 0
95}
96
97#[cfg(not(unix))]
98fn is_readable(_metadata: &std::fs::Metadata) -> bool {
99    true
100}
101
102impl Validator for Path {
103    fn meta(&self) -> &Meta {
104        &self.meta
105    }
106
107    fn meta_mut(&mut self) -> &mut Meta {
108        &mut self.meta
109    }
110
111    fn check(&self, value: &mut Value) -> Result<(), Error> {
112        let text = match value {
113            Value::String(text) => text,
114            other => {
115                return Err(Error::new(ErrorKind::Type {
116                    expected: ValueType::String,
117                    found: other.type_name(),
118                }));
119            }
120        };
121
122        let path = std::path::Path::new(text.as_str());
123
124        if self.absolute && !path.is_absolute() {
125            return Err(Error::new(ErrorKind::Format {
126                expected: "absolute path",
127            }));
128        }
129        if self.relative && path.is_absolute() {
130            return Err(Error::new(ErrorKind::Format {
131                expected: "relative path",
132            }));
133        }
134
135        if !self.extensions.is_empty() {
136            let mut matched = false;
137            if let Some(extension) = path.extension() {
138                for allowed in &self.extensions {
139                    if extension.eq_ignore_ascii_case(allowed) {
140                        matched = true;
141                        break;
142                    }
143                }
144            }
145            if !matched {
146                return Err(Error::new(ErrorKind::Format {
147                    expected: "allowed file extension",
148                }));
149            }
150        }
151
152        if !self.touches_filesystem() {
153            return Ok(());
154        }
155
156        let metadata = match std::fs::symlink_metadata(path) {
157            Ok(metadata) => metadata,
158            Err(_) => {
159                return Err(Error::new(ErrorKind::Format {
160                    expected: "existing path",
161                }));
162            }
163        };
164
165        if let Some(kind) = self.kind {
166            let file_type = metadata.file_type();
167            let ok = match kind {
168                PathKind::Dir => file_type.is_dir(),
169                PathKind::File => file_type.is_file(),
170                PathKind::Symlink => file_type.is_symlink(),
171            };
172            if !ok {
173                let expected = match kind {
174                    PathKind::Dir => "directory",
175                    PathKind::File => "file",
176                    PathKind::Symlink => "symlink",
177                };
178                return Err(Error::new(ErrorKind::Format { expected }));
179            }
180        }
181
182        if self.readable && !is_readable(&metadata) {
183            return Err(Error::new(ErrorKind::Format {
184                expected: "readable path",
185            }));
186        }
187        if self.writable && metadata.permissions().readonly() {
188            return Err(Error::new(ErrorKind::Format {
189                expected: "writable path",
190            }));
191        }
192
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn string(text: &str) -> Value {
202        Value::String(text.to_string())
203    }
204
205    #[test]
206    fn absolute_and_relative() {
207        assert!(
208            Path::new()
209                .absolute()
210                .validate(&mut string("/etc/app"))
211                .is_ok()
212        );
213        assert!(Path::new().absolute().validate(&mut string("app")).is_err());
214        assert!(
215            Path::new()
216                .relative()
217                .validate(&mut string("app/conf"))
218                .is_ok()
219        );
220    }
221
222    #[test]
223    fn extension_filter() {
224        assert!(
225            Path::new()
226                .extension("toml")
227                .validate(&mut string("a.toml"))
228                .is_ok()
229        );
230        assert!(
231            Path::new()
232                .extension("toml")
233                .validate(&mut string("a.json"))
234                .is_err()
235        );
236    }
237
238    #[test]
239    fn must_exist_uses_filesystem() {
240        // The crate manifest is guaranteed to exist when tests run.
241        let manifest = env!("CARGO_MANIFEST_DIR");
242        let mut here = string(manifest);
243        assert!(
244            Path::new()
245                .must_exist()
246                .kind(PathKind::Dir)
247                .validate(&mut here)
248                .is_ok()
249        );
250        let mut missing = string("/this/path/should/not/exist/xyzzy");
251        assert!(Path::new().must_exist().validate(&mut missing).is_err());
252    }
253
254    #[test]
255    fn format_only_never_touches_fs() {
256        // A non-existent path passes when no fs check is requested.
257        let mut value = string("/nope/not/here.toml");
258        assert!(
259            Path::new()
260                .absolute()
261                .extension("toml")
262                .validate(&mut value)
263                .is_ok()
264        );
265    }
266}