1use crate::error::{Error, ErrorKind};
2use crate::{Meta, Validator};
3use tanzim_value::{Value, ValueType};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum PathKind {
8 Dir,
9 File,
10 Symlink,
11}
12
13#[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 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 pub fn extension(mut self, extension: impl Into<String>) -> Self {
56 self.extensions.push(extension.into());
57 self
58 }
59
60 pub fn must_exist(mut self) -> Self {
62 self.must_exist = true;
63 self
64 }
65
66 pub fn kind(mut self, kind: PathKind) -> Self {
68 self.kind = Some(kind);
69 self
70 }
71
72 pub fn readable(mut self) -> Self {
74 self.readable = true;
75 self
76 }
77
78 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#[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 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 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}