1use hasp_core::{
20 secret_mem::wrap_secret, Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString,
21};
22use std::path::PathBuf;
23use url::Url;
24
25pub struct FileUrl {
30 pub path: PathBuf,
31 pub raw: bool,
32 pub hidden: bool,
34 pub follow_symlinks: bool,
36}
37
38impl TryFrom<&Url> for FileUrl {
39 type Error = Error;
40
41 fn try_from(url: &Url) -> Result<Self, Self::Error> {
42 if url.scheme() != "file" {
43 return Err(Error::InvalidUrl("expected file:// scheme".into()));
44 }
45
46 let host = url.host_str();
47 let is_localhost = host.is_none_or(|h| h == "localhost");
48 let is_relative = host == Some(".");
49
50 if !is_localhost && !is_relative {
51 return Err(Error::InvalidUrl(format!(
52 "file:// host must be empty, 'localhost', or '.', got '{}'",
53 host.unwrap_or("")
54 )));
55 }
56
57 let mut raw = false;
58 let mut hidden = false;
59 let mut follow_symlinks = false;
60 for (k, v) in url.query_pairs() {
61 match k.as_ref() {
62 "raw" if v == "true" => raw = true,
63 "hidden" if v == "1" => hidden = true,
64 "follow_symlinks" if v == "1" => follow_symlinks = true,
65 _ => {
66 return Err(Error::InvalidUrl(format!(
67 "file:// unknown query parameter or value: {}={}",
68 k, v
69 )))
70 }
71 }
72 }
73
74 let path = if is_relative {
75 let p = url.path();
76 if p == "/" {
77 return Err(Error::InvalidUrl(
78 "file:// relative path must not be empty".into(),
79 ));
80 }
81 PathBuf::from(&p[1..])
82 } else {
83 url.to_file_path()
84 .map_err(|_| Error::InvalidUrl("file:// invalid absolute path".into()))?
85 };
86
87 Ok(FileUrl {
88 path,
89 raw,
90 hidden,
91 follow_symlinks,
92 })
93 }
94}
95
96pub struct FileBackend;
103
104impl Backend for FileBackend {
105 fn scheme(&self) -> &'static str {
106 "file"
107 }
108
109 fn validate(&self, url: &Url) -> Result<(), Error> {
110 FileUrl::try_from(url).map(|_| ())
111 }
112
113 fn get(&self, url: &Url) -> Result<SecretString, Error> {
114 let file_url = FileUrl::try_from(url)?;
115 let mut contents =
116 std::fs::read_to_string(&file_url.path).map_err(|e| map_io_error(e, &file_url.path))?;
117 if !file_url.raw {
118 trim_one_trailing_newline(&mut contents);
119 }
120 Ok(wrap_secret(contents))
121 }
122
123 fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
124 let file_url = FileUrl::try_from(url)?;
125 if let Some(parent) = file_url.path.parent() {
126 if !parent.as_os_str().is_empty() {
127 std::fs::create_dir_all(parent).map_err(|e| map_io_error(e, parent))?;
128 }
129 }
130 std::fs::write(&file_url.path, value.expose_secret())
131 .map_err(|e| map_io_error(e, &file_url.path))?;
132 Ok(())
133 }
134
135 fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
136 let file_url = FileUrl::try_from(url)?;
137 let pattern = file_url
138 .path
139 .to_str()
140 .ok_or_else(|| Error::InvalidUrl("file:// path is not valid UTF-8".into()))?;
141
142 let canon_root = if !file_url.follow_symlinks {
150 literal_prefix(pattern).and_then(|p| std::fs::canonicalize(p).ok())
151 } else {
152 None
153 };
154
155 let mut entries = Vec::new();
156 let glob_opts = glob::MatchOptions {
157 case_sensitive: true,
158 require_literal_separator: true,
159 require_literal_leading_dot: !file_url.hidden,
160 };
161 let paths = glob::glob_with(pattern, glob_opts)
162 .map_err(|e| Error::InvalidUrl(format!("file:// invalid glob pattern: {e}")))?;
163
164 for result in paths {
165 let path = result.map_err(|e| Error::Backend {
166 scheme: "file",
167 kind: hasp_core::BackendFailureKind::Transient,
168 message: format!("glob traversal error: {e}"),
169 })?;
170
171 if !file_url.follow_symlinks {
173 if let Ok(meta) = std::fs::symlink_metadata(&path) {
174 if meta.file_type().is_symlink() {
175 continue;
176 }
177 }
178 if let Some(root) = &canon_root {
185 match std::fs::canonicalize(&path) {
186 Ok(canon) if canon.starts_with(root) => {}
187 _ => continue,
188 }
189 }
190 }
191
192 if !path.is_file() {
196 continue;
197 }
198
199 let path_url = Url::from_file_path(&path).map_err(|_| Error::Backend {
200 scheme: "file",
201 kind: hasp_core::BackendFailureKind::Permanent,
202 message: format!("cannot convert path to URL: {}", path.display()),
203 })?;
204 let name = path.to_string_lossy().into_owned();
205 entries.push(Entry {
206 name,
207 url: path_url,
208 });
209 }
210
211 Ok(entries)
212 }
213
214 fn delete(&self, url: &Url) -> Result<(), Error> {
215 let file_url = FileUrl::try_from(url)?;
216 std::fs::remove_file(&file_url.path).map_err(|e| map_io_error(e, &file_url.path))?;
217 Ok(())
218 }
219
220 fn exists(&self, url: &Url) -> Result<bool, Error> {
221 let file_url = FileUrl::try_from(url)?;
222 Ok(file_url.path.exists())
223 }
224}
225
226fn literal_prefix(pattern: &str) -> Option<std::path::PathBuf> {
231 let stop = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
232 let head = &pattern[..stop];
233 let last_sep = head.rfind('/')?;
234 Some(std::path::PathBuf::from(&head[..=last_sep]))
235}
236
237fn trim_one_trailing_newline(s: &mut String) {
243 if s.ends_with("\r\n") {
244 let new_len = s.len().saturating_sub(2);
245 s.truncate(new_len);
246 } else if s.ends_with('\n') {
247 let new_len = s.len().saturating_sub(1);
248 s.truncate(new_len);
249 }
250}
251
252fn map_io_error(err: std::io::Error, path: &std::path::Path) -> Error {
253 use std::io::ErrorKind;
254 match err.kind() {
255 ErrorKind::NotFound => Error::NotFound(format!("file not found: {}", path.display())),
256 ErrorKind::PermissionDenied => {
257 Error::PermissionDenied(format!("permission denied: {}", path.display()))
258 }
259 ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted => Error::Backend {
260 scheme: "file",
261 kind: BackendFailureKind::Transient,
262 message: format!("file I/O transient failure: {err}"),
263 },
264 _ => Error::Backend {
265 scheme: "file",
266 kind: BackendFailureKind::Permanent,
267 message: format!("file I/O permanent failure: {err}"),
268 },
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn parse_absolute_url() {
278 let url = Url::parse("file:///etc/secrets/db.txt").unwrap();
279 let f = FileUrl::try_from(&url).unwrap();
280 assert_eq!(f.path, PathBuf::from("/etc/secrets/db.txt"));
281 assert!(!f.raw);
282 }
283
284 #[test]
285 fn parse_localhost_url() {
286 let url = Url::parse("file://localhost/etc/secrets/db.txt").unwrap();
287 let f = FileUrl::try_from(&url).unwrap();
288 assert_eq!(f.path, PathBuf::from("/etc/secrets/db.txt"));
289 assert!(!f.raw);
290 }
291
292 #[test]
293 fn parse_relative_url() {
294 let url = Url::parse("file://./secrets/db.txt").unwrap();
295 let f = FileUrl::try_from(&url).unwrap();
296 assert_eq!(f.path, PathBuf::from("secrets/db.txt"));
297 assert!(!f.raw);
298 }
299
300 #[test]
301 fn parse_raw_true() {
302 let url = Url::parse("file:///etc/secrets/db.txt?raw=true").unwrap();
303 let f = FileUrl::try_from(&url).unwrap();
304 assert_eq!(f.path, PathBuf::from("/etc/secrets/db.txt"));
305 assert!(f.raw);
306 }
307
308 #[test]
309 fn parse_invalid_host_fails() {
310 let url = Url::parse("file://otherhost/etc/secrets/db.txt").unwrap();
311 assert!(FileUrl::try_from(&url).is_err());
312 }
313
314 #[test]
315 fn parse_unknown_query_fails() {
316 let url = Url::parse("file:///etc/secrets/db.txt?foo=bar").unwrap();
317 assert!(FileUrl::try_from(&url).is_err());
318 }
319
320 #[test]
321 fn parse_relative_empty_path_fails() {
322 let url = Url::parse("file://./").unwrap();
323 assert!(FileUrl::try_from(&url).is_err());
324 }
325
326 #[test]
327 fn trim_crlf() {
328 let mut s = "hello\r\n".to_string();
329 trim_one_trailing_newline(&mut s);
330 assert_eq!(s, "hello");
331 }
332
333 #[test]
334 fn trim_lf() {
335 let mut s = "hello\n".to_string();
336 trim_one_trailing_newline(&mut s);
337 assert_eq!(s, "hello");
338 }
339
340 #[test]
341 fn trim_prefers_crlf() {
342 let mut s = "hello\r\n\n".to_string();
343 trim_one_trailing_newline(&mut s);
344 assert_eq!(s, "hello\r\n");
345 }
346
347 #[test]
348 fn trim_no_op_when_no_newline() {
349 let mut s = "hello".to_string();
350 trim_one_trailing_newline(&mut s);
351 assert_eq!(s, "hello");
352 }
353
354 #[test]
355 fn backend_get_roundtrip_and_trim() {
356 let dir = tempfile::tempdir().unwrap();
357 let path = dir.path().join("secret.txt");
358 std::fs::write(&path, "my-secret\n").unwrap();
359
360 let backend = FileBackend;
361 let url = Url::from_file_path(&path).unwrap();
362 let secret = backend.get(&url).unwrap();
363 assert_eq!(secret.expose_secret(), "my-secret");
364 }
365
366 #[test]
367 fn backend_get_raw_no_trim() {
368 let dir = tempfile::tempdir().unwrap();
369 let path = dir.path().join("secret.txt");
370 std::fs::write(&path, "my-secret\n").unwrap();
371
372 let backend = FileBackend;
373 let mut url = Url::from_file_path(&path).unwrap();
374 url.query_pairs_mut().append_pair("raw", "true");
375 let secret = backend.get(&url).unwrap();
376 assert_eq!(secret.expose_secret(), "my-secret\n");
377 }
378
379 #[test]
380 fn backend_put_creates_parent_dirs() {
381 let dir = tempfile::tempdir().unwrap();
382 let path = dir.path().join("nested/secret.txt");
383
384 let backend = FileBackend;
385 let url = Url::from_file_path(&path).unwrap();
386 let value = SecretString::new("new-secret".into());
387 backend.put(&url, &value).unwrap();
388
389 let contents = std::fs::read_to_string(&path).unwrap();
390 assert_eq!(contents, "new-secret");
391 }
392
393 #[test]
394 fn backend_exists_and_delete() {
395 let dir = tempfile::tempdir().unwrap();
396 let path = dir.path().join("to-delete.txt");
397 std::fs::write(&path, "value").unwrap();
398
399 let backend = FileBackend;
400 let url = Url::from_file_path(&path).unwrap();
401
402 assert!(backend.exists(&url).unwrap());
403 backend.delete(&url).unwrap();
404 assert!(!backend.exists(&url).unwrap());
405 }
406
407 #[test]
408 fn backend_get_not_found() {
409 let backend = FileBackend;
410 let url = Url::parse("file:///nonexistent/path/to/secret.txt").unwrap();
411 let err = backend.get(&url).unwrap_err();
412 assert!(matches!(err, Error::NotFound(_)));
413 }
414
415 #[test]
416 fn backend_list_no_match_returns_empty() {
417 let dir = tempfile::tempdir().unwrap();
418 let backend = FileBackend;
419 let pattern = format!("{}/*.nomatch", dir.path().display());
420 let url = Url::parse(&format!("file://{pattern}")).unwrap();
421 let entries = backend.list(&url).unwrap();
422 assert!(entries.is_empty());
423 }
424}