Skip to main content

secrets_rs/sources/
file.rs

1use std::io;
2use std::path::{Path, PathBuf};
3
4use crate::{error::SourceError, source::Source};
5
6/// A [`Source`] that retrieves secrets from the local filesystem.
7///
8/// Two construction modes are available:
9///
10/// - **[`FileSource::new()`]** — resolves relative paths against the process's
11///   current working directory at the time [`get`](Source::get) is called.
12///   Simple, but the result is non-deterministic if any code calls
13///   [`std::env::set_current_dir`] concurrently.
14///
15/// - **[`FileSource::with_base(dir)`](FileSource::with_base)** — captures an
16///   absolute base directory at construction time and resolves all relative
17///   paths against it. The resolved path is stable regardless of later CWD
18///   changes. Absolute paths in the URN name are still used as-is.
19///
20/// The primary use case is loading keys and certificates, e.g.:
21///
22/// ```text
23/// urn:secrets-rs:file:/etc/ssl/private/server.key   // absolute — same in both modes
24/// urn:secrets-rs:file:certs/ca.crt                  // relative — stable only with with_base
25/// ```
26///
27/// # Security
28///
29/// Because the URN name is used as a filesystem path without further
30/// validation, binding a `FileSource` secret with an attacker-controlled URN
31/// is an **arbitrary file-read** vulnerability. Only bind URNs that come from
32/// **trusted configuration** (static code, operator-supplied config files with
33/// restricted write permissions, etc.). Never construct or accept
34/// `urn:secrets-rs:file:...` URNs from untrusted input such as API requests,
35/// user-supplied data, or deserialized network payloads.
36///
37/// [`with_base`](FileSource::with_base) anchors relative resolution to a known
38/// directory but does **not** prevent path-traversal sequences (`../`) in the
39/// URN name from escaping that directory; the trusted-configuration requirement
40/// still applies.
41pub struct FileSource {
42    base: Option<PathBuf>,
43}
44
45impl FileSource {
46    /// Creates a `FileSource` that resolves relative paths against the
47    /// process's current working directory at call time.
48    pub fn new() -> Self {
49        Self { base: None }
50    }
51
52    /// Creates a `FileSource` that resolves relative paths against `base`.
53    ///
54    /// `base` is captured at construction time, so subsequent calls to
55    /// [`std::env::set_current_dir`] do not affect resolution. For stable
56    /// behaviour `base` should be an absolute path; if it is relative it is
57    /// stored as-is and still subject to CWD changes.
58    ///
59    /// Absolute paths in the URN name are used as-is regardless of `base`.
60    pub fn with_base(base: impl Into<PathBuf>) -> Self {
61        Self {
62            base: Some(base.into()),
63        }
64    }
65
66    fn resolve(&self, name: &str) -> PathBuf {
67        let p = Path::new(name);
68        if p.is_absolute() {
69            p.to_path_buf()
70        } else if let Some(base) = &self.base {
71            base.join(p)
72        } else {
73            p.to_path_buf()
74        }
75    }
76}
77
78impl Default for FileSource {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl Source for FileSource {
85    fn get(&self, name: &str) -> Result<Vec<u8>, SourceError> {
86        let path = self.resolve(name);
87        std::fs::read(&path).map_err(|e| match e.kind() {
88            io::ErrorKind::NotFound => SourceError::NotFound {
89                name: name.to_owned(),
90            },
91            _ => SourceError::Other(format!("failed to read file `{}`: {}", name, e)),
92        })
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use std::io::Write;
99
100    use super::*;
101    use tempfile::NamedTempFile;
102
103    #[test]
104    fn returns_bytes_for_existing_file() {
105        let mut f = NamedTempFile::new().unwrap();
106        f.write_all(b"file-secret").unwrap();
107        let result = FileSource::new().get(f.path().to_str().unwrap()).unwrap();
108        assert_eq!(result, b"file-secret");
109    }
110
111    #[test]
112    fn returns_not_found_for_missing_file() {
113        let dir = tempfile::tempdir().unwrap();
114        let missing = dir.path().join("nonexistent.key");
115        let err = FileSource::new()
116            .get(missing.to_str().unwrap())
117            .unwrap_err();
118        assert!(matches!(err, SourceError::NotFound { .. }));
119    }
120
121    #[test]
122    fn with_base_resolves_relative_against_base() {
123        let dir = tempfile::tempdir().unwrap();
124        std::fs::write(dir.path().join("secret.txt"), b"base-secret").unwrap();
125
126        let src = FileSource::with_base(dir.path());
127        let result = src.get("secret.txt").unwrap();
128        assert_eq!(result, b"base-secret");
129    }
130
131    #[test]
132    fn with_base_absolute_name_ignores_base() {
133        let base_dir = tempfile::tempdir().unwrap();
134        let other_dir = tempfile::tempdir().unwrap();
135        std::fs::write(other_dir.path().join("abs.txt"), b"abs-secret").unwrap();
136
137        let abs_path = other_dir.path().join("abs.txt");
138        let src = FileSource::with_base(base_dir.path());
139        let result = src.get(abs_path.to_str().unwrap()).unwrap();
140        assert_eq!(result, b"abs-secret");
141    }
142
143    #[test]
144    fn with_base_not_found_uses_original_name_in_error() {
145        let base_dir = tempfile::tempdir().unwrap();
146        let src = FileSource::with_base(base_dir.path());
147        let err = src.get("missing.key").unwrap_err();
148        assert!(
149            matches!(&err, SourceError::NotFound { name } if name == "missing.key"),
150            "unexpected error: {err:?}"
151        );
152    }
153
154    #[test]
155    fn other_error_uses_original_name_not_resolved_path() {
156        // Reading a directory produces EISDIR (an Other-class error).
157        // The error message must contain the original URN name, not the
158        // resolved path, so the base directory is never disclosed.
159        let base_dir = tempfile::tempdir().unwrap();
160        let sub_dir = base_dir.path().join("subdir");
161        std::fs::create_dir(&sub_dir).unwrap();
162
163        let src = FileSource::with_base(base_dir.path());
164        let err = src.get("subdir").unwrap_err();
165
166        let msg = match &err {
167            SourceError::Other(m) => m.clone(),
168            other => panic!("expected Other, got {other:?}"),
169        };
170        assert!(msg.contains("subdir"), "original name missing from: {msg}");
171        assert!(
172            !msg.contains(base_dir.path().to_str().unwrap()),
173            "base directory disclosed in: {msg}"
174        );
175    }
176}