Skip to main content

hasp_backend_file/
lib.rs

1//! `file://` backend for hasp.
2//!
3//! Grammar:
4//! - `file:///absolute/path` — absolute path (host empty or `localhost`)
5//! - `file://./relative/path` — relative to current working directory (host = `.`)
6//! - `?raw=true` — disables the default newline trim (get only)
7//!
8//! `list` supports glob patterns in the path component:
9//! - `file:///etc/secrets/*.key`
10//! - `file:///etc/secrets/**/*.key`
11//!
12//! Query params for `list`:
13//! - `?hidden=1` — include dotfiles (default: exclude)
14//! - `?follow_symlinks=1` — follow symlinks during `**` traversal
15//!   (default: skip — prevents escaping the intended tree)
16//!
17//! Supported operations: `get`, `put`, `exists`, `delete`, `list`.
18
19use hasp_core::{
20    secret_mem::wrap_secret, Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString,
21};
22use std::path::PathBuf;
23use url::Url;
24
25/// URL shape for `file://` addresses.
26///
27/// `path` is the platform-native file path extracted from the URL.
28/// `raw` disables the default newline trimming performed on `get`.
29pub struct FileUrl {
30    pub path: PathBuf,
31    pub raw: bool,
32    /// Include dotfiles in `list` results.
33    pub hidden: bool,
34    /// Follow symlinks during `**` glob traversal in `list`.
35    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
96/// Stdlib-only backend that reads secrets from files.
97///
98/// Default behavior strips exactly one trailing `\n` or `\r\n` from the
99/// file contents — matching the dominant convention for Docker secrets,
100/// Kubernetes secrets, and systemd-creds. Use `?raw=true` for verbatim
101/// bytes.
102pub 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        // Containment root: the longest path prefix of `pattern` that
143        // contains no glob metacharacters. When `follow_symlinks =
144        // false` we require every returned path's canonical form to
145        // remain under the canonical root, which closes the
146        // symlink-directory-mid-pattern escape the leaf
147        // `symlink_metadata` check below does NOT cover. (`glob`'s
148        // `**` traversal follows symlinked directories regardless.)
149        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            // Leaf symlink filter: skip if the leaf itself is a symlink.
172            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                // Containment: reject any candidate whose resolved
179                // canonical form is outside the canonical pattern
180                // root. Catches symlinked subdirectories that `glob`'s
181                // `**` traversal silently followed. If we cannot
182                // canonicalize either side, drop the candidate — safer
183                // to under-report than to leak an escape.
184                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            // Only emit paths that point to regular files (not dirs).
193            // This matches the get/put contract: every Entry URL is
194            // directly get()-able.
195            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
226/// Return the longest leading directory of `pattern` that contains no
227/// glob metacharacter (`*`, `?`, `[`). Used as the containment root
228/// for `list` so symlinked subdirectories cannot redirect a `**`
229/// traversal outside the user-named tree.
230fn 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
237/// Strips exactly one trailing `\r\n` or `\n` from the given string.
238///
239/// Mutates in place to avoid an extra allocation. This is the default
240/// behavior for `file://` reads because most secret files are created
241/// with `echo "secret" > file`, which appends an unwanted newline.
242fn 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}