dir_probe/
lib.rs

1//! A pre-flight check that a directory is genuinely usable for the
2//! create-a-temp-file-then-rename-it-into-place pattern used by
3//! databases like RocksDB and by maildir-style delivery.  See
4//! [`probe_directory`].
5
6use anyhow::Context;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use tempfile::Builder;
10
11/// Verify that the calling process can perform the file operations that
12/// a RocksDB or maildir style store relies on within `dir`: create a
13/// file, write and fsync it, atomically rename it into place, and
14/// unlink it.
15///
16/// It also cross-checks the kernel's `access(2)` view of the renamed
17/// file against the `open(2)` view.  That cross-check only bites when
18/// the real and effective uids differ; when they are the same (tests,
19/// non-root deploys, plain root) both syscalls consult one identity and
20/// can never disagree, so it is inert.  It exists for the specific
21/// failure mode of a privilege drop that keeps the real uid as root (to
22/// retain a capability) while lowering the effective uid to a service
23/// user:
24///
25/// - `access(2)` answers using the real uid (root here).
26/// - `open(2)`, `rename(2)`, and `unlink(2)` act using the effective
27///   (filesystem) uid (the service user here).
28/// - On a directory whose group and other permission bits have been
29///   stripped (for example a root-owned `mkdir` under a `077` umask,
30///   later `chown`ed to the service user), those two identities
31///   disagree about whether a file is reachable.
32///
33/// RocksDB decides whether to open an existing database or create a
34/// fresh one via an `access(2)`-style existence check, then does its
35/// I/O via `open`/`rename`/`unlink`.  When the two disagree it silently
36/// creates a brand new database and aborts on the pre-existing
37/// write-ahead log, which the operator sees only as an opaque
38/// "wal_dir contains existing log file" failure on the second startup.
39/// Running this probe first turns that late, cryptic corruption into an
40/// early, actionable error.
41///
42/// The check runs as whatever real/effective/filesystem identity the
43/// process currently has, so it needs no assumptions about which user
44/// or which permission bits are "correct".  On failure the returned
45/// error names the directory owner, mode, and the process ids so the
46/// operator can see the mismatch.
47pub fn probe_directory(dir: &Path) -> anyhow::Result<()> {
48    // Create a file the way RocksDB creates its WAL and MANIFEST files.
49    let source = Builder::new()
50        .prefix(".kumo-dir-probe")
51        .tempfile_in(dir)
52        .with_context(|| {
53            format!(
54                "unable to create a file in {}. {}",
55                dir.display(),
56                describe_context(dir)
57            )
58        })?;
59
60    source
61        .as_file()
62        .write_all(b"kumo dir probe")
63        .and_then(|_| source.as_file().sync_all())
64        .with_context(|| {
65            format!(
66                "unable to write and fsync a file in {}. {}",
67                dir.display(),
68                describe_context(dir)
69            )
70        })?;
71
72    // Atomically install it under a new name, mirroring RocksDB's rename
73    // of CURRENT.dbtmp onto CURRENT.  The target reuses the unique name
74    // that tempfile chose for the source, so it can't collide either.
75    let source_path = source.into_temp_path();
76    let mut target = source_path.as_os_str().to_owned();
77    target.push(".renamed");
78    let target = PathBuf::from(target);
79    std::fs::rename(&source_path, &target).with_context(|| {
80        format!(
81            "unable to rename a file within {}. {}",
82            dir.display(),
83            describe_context(dir)
84        )
85    })?;
86
87    let consistency = consistency_check(&target).with_context(|| describe_context(dir));
88
89    // Unlink the way RocksDB unlinks obsolete files.  This also cleans
90    // up the probe artifact regardless of the outcome above.
91    let unlink = std::fs::remove_file(&target).with_context(|| {
92        format!(
93            "unable to unlink a file within {}. {}",
94            dir.display(),
95            describe_context(dir)
96        )
97    });
98
99    // Both cleanup and the verdict are bound before either `?` so the
100    // probe file is always removed, even when the consistency check
101    // fails.  Report the consistency verdict first because it is the
102    // more informative diagnosis; a refactor that removes this eager
103    // binding would reintroduce a leak on the error path.
104    consistency?;
105    unlink?;
106    Ok(())
107}
108
109/// Compare the `access(2)` existence verdict (evaluated against the real
110/// uid/gid) with the `open(2)` verdict (evaluated against the effective
111/// filesystem identity).  They can only disagree when the two identities
112/// differ and the directory permissions favor one over the other, which
113/// is the condition that silently corrupts a RocksDB opened in this
114/// directory.
115#[cfg(unix)]
116fn consistency_check(target: &Path) -> anyhow::Result<()> {
117    use nix::unistd::{access, AccessFlags};
118
119    // F_OK deliberately tests only existence plus parent-directory
120    // search, matching how RocksDB's FileExists probes for CURRENT.  Do
121    // not "upgrade" this to W_OK: writability is not the question, and
122    // changing it would alter what divergence we detect.
123    let access_present = access(target, AccessFlags::F_OK).is_ok();
124    let open_present = std::fs::File::open(target).is_ok();
125
126    anyhow::ensure!(
127        access_present == open_present,
128        "inconsistent view of {}: access(2) reports the file present={access_present} \
129         but open(2) reports it present={open_present}. This means the process real and \
130         effective user ids differ (a privilege drop) and the directory permissions are \
131         too restrictive for one of those identities. A database opened here would decide \
132         to create a fresh instance yet write over the existing files, corrupting itself \
133         on the next startup. Ensure the directory is owned by, and grants rwx to, the \
134         identity the service runs as.",
135        target.display(),
136    );
137
138    Ok(())
139}
140
141#[cfg(not(unix))]
142fn consistency_check(_target: &Path) -> anyhow::Result<()> {
143    Ok(())
144}
145
146#[cfg(unix)]
147fn describe_context(dir: &Path) -> String {
148    use nix::unistd::{getegid, geteuid, getgid, getuid};
149    use std::os::unix::fs::MetadataExt;
150
151    let dir_info = match std::fs::metadata(dir) {
152        Ok(md) => format!(
153            "directory owner uid={} gid={} mode={:o}",
154            md.uid(),
155            md.gid(),
156            md.mode() & 0o7777
157        ),
158        Err(err) => format!("directory metadata unavailable: {err}"),
159    };
160
161    format!(
162        "{} ({dir_info}); process ruid={} euid={} rgid={} egid={}",
163        dir.display(),
164        getuid(),
165        geteuid(),
166        getgid(),
167        getegid(),
168    )
169}
170
171#[cfg(not(unix))]
172fn describe_context(dir: &Path) -> String {
173    dir.display().to_string()
174}
175
176#[cfg(test)]
177mod test {
178    use super::*;
179
180    #[test]
181    fn probe_succeeds_on_writable_dir() {
182        let dir = tempfile::tempdir().unwrap();
183        probe_directory(dir.path()).unwrap();
184        // The probe must leave nothing behind.
185        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
186            .unwrap()
187            .map(|e| e.unwrap().file_name())
188            .collect();
189        k9::assert_equal!(leftovers.len(), 0, "probe left files behind: {leftovers:?}");
190    }
191
192    #[test]
193    fn probe_fails_on_missing_dir() {
194        let dir = tempfile::tempdir().unwrap();
195        let missing = dir.path().join("does-not-exist");
196        probe_directory(&missing).unwrap_err();
197    }
198
199    #[cfg(unix)]
200    #[test]
201    fn probe_fails_on_unwritable_dir() {
202        use nix::unistd::geteuid;
203        use std::os::unix::fs::PermissionsExt;
204
205        // root bypasses the permission bits via CAP_DAC_OVERRIDE, so the
206        // unwritable case can only be exercised as a non-root user.
207        if geteuid().is_root() {
208            return;
209        }
210
211        let dir = tempfile::tempdir().unwrap();
212        let sub = dir.path().join("locked");
213        std::fs::create_dir(&sub).unwrap();
214        std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o500)).unwrap();
215
216        let err = probe_directory(&sub).unwrap_err();
217
218        // Restore write so the tempdir can be cleaned up.
219        std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
220
221        let msg = format!("{err:#}");
222        assert!(
223            msg.contains("unable to create a file"),
224            "unexpected error: {msg}"
225        );
226    }
227}