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
102crate::impl_meta_methods!(Path);
103
104impl Validator for Path {
105    fn meta(&self) -> &Meta {
106        &self.meta
107    }
108
109    fn meta_mut(&mut self) -> &mut Meta {
110        &mut self.meta
111    }
112
113    fn check(&self, value: &mut Value) -> Result<(), Error> {
114        let text = match value {
115            Value::String(text) => text,
116            other => {
117                return Err(Error::new(ErrorKind::Type {
118                    expected: ValueType::String,
119                    found: other.type_name(),
120                }));
121            }
122        };
123
124        let path = std::path::Path::new(text.as_str());
125
126        if self.absolute && !path.is_absolute() {
127            return Err(Error::new(ErrorKind::Format {
128                expected: "absolute path",
129            }));
130        }
131        if self.relative && path.is_absolute() {
132            return Err(Error::new(ErrorKind::Format {
133                expected: "relative path",
134            }));
135        }
136
137        if !self.extensions.is_empty() {
138            let mut matched = false;
139            if let Some(extension) = path.extension() {
140                for allowed in &self.extensions {
141                    if extension.eq_ignore_ascii_case(allowed) {
142                        matched = true;
143                        break;
144                    }
145                }
146            }
147            if !matched {
148                return Err(Error::new(ErrorKind::Format {
149                    expected: "allowed file extension",
150                }));
151            }
152        }
153
154        if !self.touches_filesystem() {
155            return Ok(());
156        }
157
158        let metadata = match std::fs::symlink_metadata(path) {
159            Ok(metadata) => metadata,
160            Err(_) => {
161                return Err(Error::new(ErrorKind::Format {
162                    expected: "existing path",
163                }));
164            }
165        };
166
167        if let Some(kind) = self.kind {
168            let file_type = metadata.file_type();
169            let ok = match kind {
170                PathKind::Dir => file_type.is_dir(),
171                PathKind::File => file_type.is_file(),
172                PathKind::Symlink => file_type.is_symlink(),
173            };
174            if !ok {
175                let expected = match kind {
176                    PathKind::Dir => "directory",
177                    PathKind::File => "file",
178                    PathKind::Symlink => "symlink",
179                };
180                return Err(Error::new(ErrorKind::Format { expected }));
181            }
182        }
183
184        if self.readable && !is_readable(&metadata) {
185            return Err(Error::new(ErrorKind::Format {
186                expected: "readable path",
187            }));
188        }
189        if self.writable && metadata.permissions().readonly() {
190            return Err(Error::new(ErrorKind::Format {
191                expected: "writable path",
192            }));
193        }
194
195        Ok(())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn string(text: &str) -> Value {
204        Value::String(text.to_string())
205    }
206
207    #[test]
208    fn absolute_and_relative() {
209        assert!(
210            Path::new()
211                .absolute()
212                .validate(&mut string("/etc/app"))
213                .is_ok()
214        );
215        assert!(Path::new().absolute().validate(&mut string("app")).is_err());
216        assert!(
217            Path::new()
218                .relative()
219                .validate(&mut string("app/conf"))
220                .is_ok()
221        );
222    }
223
224    #[test]
225    fn extension_filter() {
226        assert!(
227            Path::new()
228                .extension("toml")
229                .validate(&mut string("a.toml"))
230                .is_ok()
231        );
232        assert!(
233            Path::new()
234                .extension("toml")
235                .validate(&mut string("a.json"))
236                .is_err()
237        );
238    }
239
240    #[test]
241    fn must_exist_uses_filesystem() {
242        // The crate manifest is guaranteed to exist when tests run.
243        let manifest = env!("CARGO_MANIFEST_DIR");
244        let mut here = string(manifest);
245        assert!(
246            Path::new()
247                .must_exist()
248                .kind(PathKind::Dir)
249                .validate(&mut here)
250                .is_ok()
251        );
252        let mut missing = string("/this/path/should/not/exist/xyzzy");
253        assert!(Path::new().must_exist().validate(&mut missing).is_err());
254    }
255
256    #[test]
257    fn format_only_never_touches_fs() {
258        // A non-existent path passes when no fs check is requested.
259        let mut value = string("/nope/not/here.toml");
260        assert!(
261            Path::new()
262                .absolute()
263                .extension("toml")
264                .validate(&mut value)
265                .is_ok()
266        );
267    }
268}