Skip to main content

drep/auth/
store_file.rs

1//! Locked, atomic persistence for the credential store.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use super::{AuthError, AuthStore};
9
10impl AuthStore {
11    /// Read the store at `path`.
12    ///
13    /// A missing file is an empty store, which is the normal first-run state.
14    /// Every other read or parse failure remains fatal so corruption never
15    /// masquerades as a machine with no stored credentials.
16    ///
17    /// A parse failure carries the toml error's message alone. Its `Display`
18    /// renders the offending source line, and in this file that line is an
19    /// endpoint and its key, so the full rendering would print a credential.
20    pub fn load(path: &Path) -> Result<Self, AuthError> {
21        let content = match fs::read_to_string(path) {
22            Ok(content) => content,
23            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::new()),
24            Err(err) => return Err(AuthError::Read(path.to_path_buf(), err)),
25        };
26        toml::from_str(&content).map_err(|err: toml::de::Error| {
27            AuthError::Parse(path.to_path_buf(), err.message().to_owned())
28        })
29    }
30
31    /// Merge this snapshot's mutations into the latest store and publish it.
32    ///
33    /// A process mutex closes the gap left by advisory file locks within one
34    /// process; the sibling lock file serializes independent drep processes.
35    pub fn save(&self, path: &Path) -> Result<(), AuthError> {
36        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
37            ensure_dir_private(parent)?;
38        }
39
40        let lock_path = lock_path(path);
41        let _lock = crate::file_lock::exclusive(&lock_path)
42            .map_err(|err| AuthError::Lock(lock_path.clone(), err))?;
43
44        let mut pending = self
45            .pending
46            .lock()
47            .unwrap_or_else(std::sync::PoisonError::into_inner);
48        let mut keys = Self::load(path)?.keys;
49        for endpoint in pending.iter() {
50            if let Some(key) = self.keys.get(endpoint) {
51                keys.insert(endpoint.clone(), key.clone());
52            } else {
53                keys.remove(endpoint);
54            }
55        }
56        let body = serialize_keys(keys)?;
57        write_private(path, &body)?;
58        pending.clear();
59        Ok(())
60    }
61}
62
63fn serialize_keys(keys: BTreeMap<String, String>) -> Result<String, AuthError> {
64    let store = AuthStore {
65        keys,
66        ..AuthStore::default()
67    };
68    toml::to_string_pretty(&store).map_err(|err| AuthError::Serialize(err.to_string()))
69}
70
71fn lock_path(path: &Path) -> PathBuf {
72    let mut name = path
73        .file_name()
74        .unwrap_or_else(|| std::ffi::OsStr::new("auth.toml"))
75        .to_os_string();
76    name.push(".lock");
77    path.with_file_name(name)
78}
79
80/// Create `dir` if missing, narrowing it only when drep made it.
81///
82/// `DREP_AUTH_PATH` can name any path, so changing an existing parent's mode
83/// could turn `/etc/drep.toml` into a request to chmod `/etc`. The store file's
84/// own 0600 mode protects credentials inside a directory the user owns.
85pub(crate) fn ensure_dir_private(dir: &Path) -> Result<(), AuthError> {
86    ensure_dir_private_with(dir, create_private_dir, sync_directory)
87}
88
89fn ensure_dir_private_with(
90    dir: &Path,
91    create_component: impl FnMut(&Path) -> std::io::Result<()>,
92    sync_directory: impl FnMut(&Path) -> std::io::Result<()>,
93) -> Result<(), AuthError> {
94    ensure_dir_private_with_metadata(
95        dir,
96        |path| fs::symlink_metadata(path),
97        create_component,
98        sync_directory,
99    )
100}
101
102fn ensure_dir_private_with_metadata(
103    dir: &Path,
104    mut metadata: impl FnMut(&Path) -> std::io::Result<fs::Metadata>,
105    mut create_component: impl FnMut(&Path) -> std::io::Result<()>,
106    mut sync_directory: impl FnMut(&Path) -> std::io::Result<()>,
107) -> Result<(), AuthError> {
108    let mut missing = Vec::new();
109    let mut cursor = dir;
110    loop {
111        match metadata(cursor) {
112            Ok(_) => break,
113            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
114                missing.push(cursor.to_path_buf());
115            }
116            Err(err) => return Err(AuthError::Write(dir.to_path_buf(), err)),
117        }
118        let Some(parent) = cursor
119            .parent()
120            .filter(|parent| !parent.as_os_str().is_empty())
121        else {
122            break;
123        };
124        cursor = parent;
125    }
126
127    for created in missing.iter().rev() {
128        match create_component(created) {
129            Ok(()) => {
130                restrict(created, 0o700)?;
131                sync_directory(created)
132                    .map_err(|err| AuthError::Write(created.to_path_buf(), err))?;
133                sync_directory(temporary_parent(created))
134                    .map_err(|err| AuthError::Write(created.to_path_buf(), err))?;
135            }
136            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
137                require_directory(created)?;
138            }
139            Err(err) => return Err(AuthError::Write(created.to_path_buf(), err)),
140        }
141    }
142    require_directory(dir)
143}
144
145fn create_private_dir(path: &Path) -> std::io::Result<()> {
146    #[cfg(unix)]
147    {
148        use std::os::unix::fs::DirBuilderExt;
149
150        let mut builder = fs::DirBuilder::new();
151        builder.mode(0o700).create(path)
152    }
153
154    #[cfg(not(unix))]
155    {
156        fs::create_dir(path)
157    }
158}
159
160fn require_directory(path: &Path) -> Result<(), AuthError> {
161    match fs::metadata(path) {
162        Ok(metadata) if metadata.is_dir() => Ok(()),
163        Ok(_) => Err(AuthError::Write(
164            path.to_path_buf(),
165            std::io::Error::from(std::io::ErrorKind::NotADirectory),
166        )),
167        Err(err) => Err(AuthError::Write(path.to_path_buf(), err)),
168    }
169}
170
171/// Write through a random private sibling and atomically replace `path`.
172///
173/// The temporary starts private, is synced before publication, and has a
174/// random exclusively-created name. That avoids a world-readable write window,
175/// partial files after a crash, and predictable sibling-symlink attacks.
176fn write_private(path: &Path, body: &str) -> Result<(), AuthError> {
177    write_private_with(path, body, sync_directory)
178}
179
180fn write_private_with(
181    path: &Path,
182    body: &str,
183    sync_parent: impl FnOnce(&Path) -> std::io::Result<()>,
184) -> Result<(), AuthError> {
185    let parent = temporary_parent(path);
186    let mut temporary = tempfile::NamedTempFile::new_in(parent)
187        .map_err(|err| AuthError::Write(path.to_path_buf(), err))?;
188    temporary
189        .write_all(body.as_bytes())
190        .map_err(|err| AuthError::Write(path.to_path_buf(), err))?;
191    temporary
192        .as_file()
193        .sync_all()
194        .map_err(|err| AuthError::Write(path.to_path_buf(), err))?;
195    restrict(temporary.path(), 0o600)?;
196    temporary
197        .persist(path)
198        .map_err(|err| AuthError::Write(path.to_path_buf(), err.error))?;
199    sync_parent(parent).map_err(|err| AuthError::Write(path.to_path_buf(), err))
200}
201
202#[cfg(unix)]
203fn sync_directory(path: &Path) -> std::io::Result<()> {
204    fs::File::open(path)?.sync_all()
205}
206
207#[cfg(not(unix))]
208fn sync_directory(_path: &Path) -> std::io::Result<()> {
209    Ok(())
210}
211
212pub(crate) fn temporary_parent(path: &Path) -> &Path {
213    path.parent()
214        .filter(|parent| !parent.as_os_str().is_empty())
215        .unwrap_or_else(|| Path::new("."))
216}
217
218#[cfg(unix)]
219pub(crate) fn restrict(path: &Path, mode: u32) -> Result<(), AuthError> {
220    use std::os::unix::fs::PermissionsExt;
221    fs::set_permissions(path, fs::Permissions::from_mode(mode))
222        .map_err(|err| AuthError::Write(path.to_path_buf(), err))
223}
224
225#[cfg(not(unix))]
226// Windows has no Unix mode bits. ProjectDirs places the store under the
227// current user's roaming profile, where new files inherit that profile's ACL;
228// applying a numeric 0600 would neither describe nor improve that boundary.
229pub(crate) fn restrict(_path: &Path, _mode: u32) -> Result<(), AuthError> {
230    Ok(())
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    #[cfg(unix)]
237    use crate::test_support::assert_mode;
238
239    fn assert_write_error(error: AuthError, path: &Path, kind: std::io::ErrorKind) {
240        match error {
241            AuthError::Write(actual_path, source) => {
242                assert_eq!(actual_path, path);
243                assert_eq!(source.kind(), kind);
244            }
245            other => panic!("expected write error, got {other:?}"),
246        }
247    }
248
249    #[test]
250    fn metadata_errors_are_not_treated_as_missing_directories() {
251        let dir = Path::new("denied");
252        let error = ensure_dir_private_with_metadata(
253            dir,
254            |_| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
255            |_| panic!("metadata failure must not attempt directory creation"),
256            |_| panic!("metadata failure must not attempt directory sync"),
257        )
258        .expect_err("metadata failure must remain fatal");
259
260        assert_write_error(error, dir, std::io::ErrorKind::PermissionDenied);
261    }
262
263    #[test]
264    fn creation_errors_are_not_treated_as_races() {
265        let base = tempfile::tempdir().expect("tempdir");
266        let dir = base.path().join("denied");
267        let error = ensure_dir_private_with(
268            &dir,
269            |_| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
270            |_| panic!("failed directory creation must not be synced"),
271        )
272        .expect_err("creation failure must remain fatal");
273
274        assert_write_error(error, &dir, std::io::ErrorKind::PermissionDenied);
275    }
276
277    #[test]
278    fn a_store_parent_must_be_a_directory() {
279        let base = tempfile::tempdir().expect("tempdir");
280        let file = base.path().join("not-a-directory");
281        std::fs::write(&file, "data").expect("fixture file");
282
283        let error = require_directory(&file).expect_err("file must not pass as directory");
284
285        assert_write_error(error, &file, std::io::ErrorKind::NotADirectory);
286    }
287
288    #[test]
289    fn private_directory_creation_creates_the_requested_path() {
290        let base = tempfile::tempdir().expect("tempdir");
291        let dir = base.path().join("private");
292
293        create_private_dir(&dir).expect("create private directory");
294
295        assert!(dir.is_dir());
296        #[cfg(unix)]
297        assert_mode(&dir, 0o700);
298    }
299
300    #[cfg(unix)]
301    #[test]
302    fn syncing_a_missing_directory_reports_not_found() {
303        let base = tempfile::tempdir().expect("tempdir");
304        let missing = base.path().join("missing");
305
306        let error = sync_directory(&missing).expect_err("missing directory cannot be synced");
307
308        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
309    }
310
311    #[test]
312    fn publication_syncs_the_parent_after_the_store_is_visible() {
313        let dir = tempfile::tempdir().expect("tempdir");
314        let path = dir.path().join("auth.toml");
315        let mut called = false;
316
317        write_private_with(&path, "[keys]\n", |parent| {
318            assert_eq!(parent, dir.path());
319            assert_eq!(
320                std::fs::read_to_string(&path).expect("published store"),
321                "[keys]\n"
322            );
323            called = true;
324            Ok(())
325        })
326        .expect("publish and sync");
327
328        assert!(called, "the containing directory was not synced");
329    }
330
331    #[test]
332    fn creating_nested_store_directories_syncs_each_new_link() {
333        let base = tempfile::tempdir().expect("tempdir");
334        let dir = base.path().join("first").join("second");
335        let mut seen = Vec::new();
336
337        ensure_dir_private_with(
338            &dir,
339            |component| {
340                std::fs::create_dir(component)?;
341                #[cfg(unix)]
342                {
343                    use std::os::unix::fs::PermissionsExt;
344                    std::fs::set_permissions(component, std::fs::Permissions::from_mode(0o755))?;
345                }
346                Ok(())
347            },
348            |directory| {
349                assert!(directory.exists(), "directory was not visible before sync");
350                seen.push(directory.to_path_buf());
351                Ok(())
352            },
353        )
354        .expect("create and sync directory tree");
355
356        assert_eq!(
357            seen,
358            vec![
359                base.path().join("first"),
360                base.path().to_path_buf(),
361                dir.clone(),
362                base.path().join("first"),
363            ]
364        );
365        #[cfg(unix)]
366        {
367            for created in [base.path().join("first"), dir] {
368                assert_mode(&created, 0o700);
369            }
370        }
371    }
372
373    #[cfg(unix)]
374    #[test]
375    fn a_directory_created_by_a_racing_process_is_not_claimed() {
376        use std::os::unix::fs::PermissionsExt;
377
378        let base = tempfile::tempdir().expect("tempdir");
379        let dir = base.path().join("raced");
380
381        ensure_dir_private_with(
382            &dir,
383            |component| {
384                std::fs::create_dir(component)?;
385                std::fs::set_permissions(component, std::fs::Permissions::from_mode(0o755))?;
386                Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists))
387            },
388            |_| panic!("a directory drep did not create must not be synced as its own"),
389        )
390        .expect("accept raced directory");
391
392        assert_mode(&dir, 0o755);
393    }
394}