Skip to main content

dig_logging/
dirs.rs

1//! Log-directory resolution (SPEC §3).
2//!
3//! One machine-wide log root, one subdirectory per service, SYSTEM/root-writable and
4//! **user-readable** — deliberately unlike the owner-only #501 state dirs, because logs are operator
5//! diagnostics (secrets are barred at source and redacted at bundle). Resolution precedence:
6//!
7//! 1. `DIG_LOG_DIR` — its value is the log ROOT; the service dir is `$DIG_LOG_DIR/<service>`.
8//! 2. the per-OS machine root, when that service dir can be created + written (a privileged run).
9//! 3. the per-user dev fallback, when the machine root is not creatable/writable (an unprivileged
10//!    `cargo run`), mirroring dig-node's #501 dev-fallback pattern.
11//!
12//! The CLI and the service resolve identically, so `<bin> logs path` names the directory the service
13//! writes to. Resolution is a PURE function of an injected env-getter + a "can this dir be created
14//! and written?" probe, so every branch is table-testable without touching the real filesystem or
15//! environment.
16//!
17//! **Operator read on Windows (#728).** The machine root lives under `%ProgramData%\DigNetwork`,
18//! which dig-installer #715 locks to a protected, non-inheriting DACL of `{SYSTEM:F, Administrators:F}`
19//! (`icacls /inheritance:r`). The `logs\<service>` subtree therefore inherits NO non-admin read from
20//! that root. To keep logs operator-readable (SPEC §3), dig-logging follows the #715 adopter rule — a
21//! child needing non-admin read sets its OWN explicit ACE — and grants `BUILTIN\Users` a read+execute
22//! ACE on the machine service dir. This never loosens the #715 root DACL (a sibling subtree gets its
23//! own ACE; the root is untouched). Applied ONLY to the machine-root branch: the `DIG_LOG_DIR` override
24//! and the per-user dev fallback are caller-chosen / already user-owned and are left alone.
25
26use std::path::PathBuf;
27
28/// The env var that overrides the log ROOT outright (tests, custom deploys).
29pub const ENV_LOG_DIR: &str = "DIG_LOG_DIR";
30
31/// The `BUILTIN\Users` group SID — locale-independent, granted operator read on the machine log dir
32/// (#728). A well-known SID (not a localized name) so the `icacls` grant works on any Windows locale.
33const SID_USERS: &str = "S-1-5-32-545";
34
35/// Which resolution branch produced a log directory (SPEC §3). Distinguishes the machine root — the
36/// only branch under the #715-locked `%ProgramData%\DigNetwork` tree, and thus the only one needing an
37/// explicit operator-read ACE — from the caller-owned override and dev-fallback branches.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum LogDirSource {
40    /// The `DIG_LOG_DIR` override (a caller-chosen root).
41    Override,
42    /// The per-OS machine root (privileged run) — under the #715-locked root on Windows.
43    MachineRoot,
44    /// The per-user dev fallback (unprivileged run) — already owned by the running user.
45    DevFallback,
46}
47
48/// A resolved log directory plus the branch that produced it (see [`LogDirSource`]).
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ResolvedLogDir {
51    /// The service log directory (`<root>/<service>`).
52    pub path: PathBuf,
53    /// The resolution branch that chose `path`.
54    pub source: LogDirSource,
55}
56
57/// Resolve the log directory for `service` from an injected env-getter and a dir-creatable probe.
58///
59/// `get` reads an env var (`None`/blank = unset). `can_create` answers "can this exact directory be
60/// created and written?" — the caller wires it to the real filesystem in [`log_dir`], and tests
61/// inject a closure to exercise the machine-root vs dev-fallback branch deterministically.
62pub fn resolve_log_dir<G, C>(service: &str, get: G, can_create: C) -> PathBuf
63where
64    G: Fn(&str) -> Option<String>,
65    C: Fn(&std::path::Path) -> bool,
66{
67    resolve_log_dir_detailed(service, get, can_create).path
68}
69
70/// Resolve the log directory AND the branch that produced it (see [`resolve_log_dir`] for precedence).
71///
72/// Callers that must know whether the machine root was chosen — the only branch under the
73/// #715-locked Windows root — use this to decide whether to apply the operator-read ACE (#728).
74pub fn resolve_log_dir_detailed<G, C>(service: &str, get: G, can_create: C) -> ResolvedLogDir
75where
76    G: Fn(&str) -> Option<String>,
77    C: Fn(&std::path::Path) -> bool,
78{
79    let read = |key: &str| {
80        get(key)
81            .map(|value| value.trim().to_string())
82            .filter(|value| !value.is_empty())
83    };
84
85    // 1. Explicit override wins unconditionally.
86    if let Some(root) = read(ENV_LOG_DIR) {
87        return ResolvedLogDir {
88            path: PathBuf::from(root).join(service),
89            source: LogDirSource::Override,
90        };
91    }
92
93    // 2. Machine root when its service dir is creatable; 3. else the per-user dev fallback.
94    let machine = machine_root(&read).join(service);
95    if can_create(&machine) {
96        return ResolvedLogDir {
97            path: machine,
98            source: LogDirSource::MachineRoot,
99        };
100    }
101    ResolvedLogDir {
102        path: dev_root(&read).join(service),
103        source: LogDirSource::DevFallback,
104    }
105}
106
107/// The resolved service log directory for this process, wiring the real environment + a filesystem
108/// creatable-probe into [`resolve_log_dir_detailed`]. When the machine-root branch is taken on Windows,
109/// grants operators (`BUILTIN\Users`) read on the freshly-created dir (#728) — see the module docs.
110pub fn log_dir(service: &str) -> PathBuf {
111    let resolved =
112        resolve_log_dir_detailed(service, |key| std::env::var(key).ok(), dir_is_writable);
113
114    #[cfg(windows)]
115    if resolved.source == LogDirSource::MachineRoot {
116        // Best-effort: a failed grant must never stop the service from logging. The dir already
117        // exists (the writability probe created and wrote it); we only relax read for operators.
118        grant_operator_read(&resolved.path);
119    }
120
121    resolved.path
122}
123
124/// Does `dir` exist (creating it if needed) AND accept a new file write? `create_dir_all` alone only
125/// proves existence — a directory an earlier privileged run already created can still refuse this
126/// process's writes (#2110: `BUILTIN\Users` held read+execute only on an installer-provisioned dir).
127/// The probe file is opened with `create_new` so it never follows or truncates a pre-existing file or
128/// symlink, and is removed immediately on success, leaving no residue behind in a writable dir.
129fn dir_is_writable(dir: &std::path::Path) -> bool {
130    if std::fs::create_dir_all(dir).is_err() {
131        return false;
132    }
133    let probe = dir.join(format!(".write-probe-{}", std::process::id()));
134    let ok = std::fs::OpenOptions::new()
135        .write(true)
136        .create_new(true)
137        .open(&probe)
138        .is_ok();
139    if ok {
140        let _ = std::fs::remove_file(&probe);
141    }
142    ok
143}
144
145/// The `icacls` argv that grants `BUILTIN\Users` a read+execute ACE, inheritable to child files/dirs,
146/// on `dir` (#728). By SID so it is locale-independent; `/grant:r` ADDS the ACE without replacing the
147/// DACL, so the inherited `{SYSTEM,Admins}` full-control from #715 (if any survives) stays intact.
148/// Pure, so the exact grant is unit-tested without touching the filesystem.
149pub fn windows_operator_read_args(dir: &str) -> Vec<String> {
150    vec![
151        dir.to_string(),
152        "/grant:r".to_string(),
153        format!("*{SID_USERS}:(OI)(CI)RX"),
154        "/T".to_string(),
155        "/C".to_string(),
156        "/Q".to_string(),
157    ]
158}
159
160/// Grant operators (`BUILTIN\Users`) read on the machine log dir via `icacls` (#728). Best-effort:
161/// any failure is swallowed, because losing operator read is a diagnostic inconvenience, never a
162/// reason to stop the service logging.
163#[cfg(windows)]
164fn grant_operator_read(dir: &std::path::Path) {
165    let Some(dir) = dir.to_str() else { return };
166    let _ = std::process::Command::new("icacls")
167        .args(windows_operator_read_args(dir))
168        .output();
169}
170
171/// The machine-wide log root (SPEC §3): `%PROGRAMDATA%\DigNetwork\logs` on Windows,
172/// `/Library/Logs/DigNetwork` on macOS, `/var/log/dig` on Linux.
173#[cfg(windows)]
174fn machine_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
175    let base = read("ProgramData").unwrap_or_else(|| r"C:\ProgramData".to_string());
176    PathBuf::from(base).join("DigNetwork").join("logs")
177}
178
179#[cfg(target_os = "macos")]
180fn machine_root<R: Fn(&str) -> Option<String>>(_read: &R) -> PathBuf {
181    PathBuf::from("/Library/Logs/DigNetwork")
182}
183
184#[cfg(all(unix, not(target_os = "macos")))]
185fn machine_root<R: Fn(&str) -> Option<String>>(_read: &R) -> PathBuf {
186    PathBuf::from("/var/log/dig")
187}
188
189/// The per-user dev-fallback log root (SPEC §3): `%LOCALAPPDATA%\DigNetwork\logs` on Windows,
190/// `~/Library/Logs/DigNetwork` on macOS, `${XDG_STATE_HOME:-~/.local/state}/dig/logs` on Linux.
191#[cfg(windows)]
192fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
193    let base = read("LOCALAPPDATA")
194        .or_else(|| read("ProgramData"))
195        .unwrap_or_else(|| r"C:\ProgramData".to_string());
196    PathBuf::from(base).join("DigNetwork").join("logs")
197}
198
199#[cfg(target_os = "macos")]
200fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
201    let home = read("HOME").unwrap_or_else(|| "/tmp".to_string());
202    PathBuf::from(home)
203        .join("Library")
204        .join("Logs")
205        .join("DigNetwork")
206}
207
208#[cfg(all(unix, not(target_os = "macos")))]
209fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
210    if let Some(state) = read("XDG_STATE_HOME") {
211        return PathBuf::from(state).join("dig").join("logs");
212    }
213    let home = read("HOME").unwrap_or_else(|| "/tmp".to_string());
214    PathBuf::from(home)
215        .join(".local")
216        .join("state")
217        .join("dig")
218        .join("logs")
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use std::collections::HashMap;
225    use std::path::Path;
226
227    /// Build an env-getter over a fixed map, so a test names exactly the vars it sets.
228    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
229        let map: HashMap<String, String> = pairs
230            .iter()
231            .map(|(k, v)| (k.to_string(), v.to_string()))
232            .collect();
233        move |key| map.get(key).cloned()
234    }
235
236    #[test]
237    fn override_wins_and_joins_service() {
238        let dir = resolve_log_dir("dig-node", env(&[(ENV_LOG_DIR, "/custom/root")]), |_| true);
239        assert_eq!(dir, Path::new("/custom/root").join("dig-node"));
240    }
241
242    #[test]
243    fn blank_override_is_ignored() {
244        // A blank override must NOT shadow the machine root (a common empty-env-var footgun).
245        let dir = resolve_log_dir("dig-dns", env(&[(ENV_LOG_DIR, "   ")]), |_| true);
246        assert!(dir.ends_with(Path::new("dig-dns")));
247        assert!(!dir.starts_with("/custom"));
248    }
249
250    #[test]
251    fn machine_root_used_when_creatable() {
252        let dir = resolve_log_dir("dig-updater", env(&[]), |_| true);
253        assert!(dir.ends_with(Path::new("dig-updater")));
254        #[cfg(all(unix, not(target_os = "macos")))]
255        assert_eq!(dir, Path::new("/var/log/dig/dig-updater"));
256        #[cfg(target_os = "macos")]
257        assert_eq!(dir, Path::new("/Library/Logs/DigNetwork/dig-updater"));
258    }
259
260    #[test]
261    fn dev_fallback_when_machine_root_not_creatable() {
262        // Simulate an unprivileged run: the machine root cannot be created, so we fall back.
263        let dir = resolve_log_dir(
264            "dig-node",
265            env(&[
266                ("HOME", "/home/dev"),
267                ("XDG_STATE_HOME", "/home/dev/.state"),
268                ("LOCALAPPDATA", r"C:\Users\dev\AppData\Local"),
269            ]),
270            |path: &Path| path.to_string_lossy().contains("dev"),
271        );
272        assert!(dir.ends_with(Path::new("dig-node")));
273        #[cfg(all(unix, not(target_os = "macos")))]
274        assert_eq!(dir, Path::new("/home/dev/.state/dig/logs/dig-node"));
275    }
276
277    #[cfg(all(unix, not(target_os = "macos")))]
278    #[test]
279    fn linux_dev_fallback_without_xdg_uses_local_state() {
280        let dir = resolve_log_dir("dig-dns", env(&[("HOME", "/home/dev")]), |_| false);
281        assert_eq!(dir, Path::new("/home/dev/.local/state/dig/logs/dig-dns"));
282    }
283
284    #[test]
285    fn override_reports_override_source() {
286        let resolved =
287            resolve_log_dir_detailed("dig-node", env(&[(ENV_LOG_DIR, "/custom")]), |_| true);
288        assert_eq!(resolved.source, LogDirSource::Override);
289    }
290
291    #[test]
292    fn creatable_machine_root_reports_machine_source() {
293        // The machine root is the ONLY branch under the #715-locked Windows root, so it is the only
294        // one that later earns the operator-read ACE (#728).
295        let resolved = resolve_log_dir_detailed("dig-node", env(&[]), |_| true);
296        assert_eq!(resolved.source, LogDirSource::MachineRoot);
297    }
298
299    #[test]
300    fn uncreatable_machine_root_reports_dev_fallback_source() {
301        let resolved =
302            resolve_log_dir_detailed("dig-node", env(&[("HOME", "/home/dev")]), |_| false);
303        assert_eq!(resolved.source, LogDirSource::DevFallback);
304    }
305
306    #[test]
307    fn operator_read_grant_targets_users_sid_read_execute_inheritable() {
308        // #728: a non-replacing (`/grant:r`) read+execute ACE for BUILTIN\Users by SID, inheritable
309        // to child files/dirs — so operators can read logs without loosening the #715 root DACL.
310        let args = windows_operator_read_args(r"C:\ProgramData\DigNetwork\logs\dig-node");
311        assert_eq!(args[0], r"C:\ProgramData\DigNetwork\logs\dig-node");
312        assert!(args.iter().any(|a| a == "/grant:r"));
313        assert!(args.iter().any(|a| a == "*S-1-5-32-545:(OI)(CI)RX"));
314        // Never a DACL-replacing flag — the inherited {SYSTEM,Admins} full-control must survive.
315        assert!(!args.iter().any(|a| a == "/inheritance:r" || a == "/reset"));
316    }
317
318    // -- #2110: create_dir_all alone is an EXISTENCE probe, not a writability probe --------------
319
320    #[cfg(unix)]
321    fn lock_dir_unwritable(dir: &Path) {
322        use std::os::unix::fs::PermissionsExt;
323        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555))
324            .expect("chmod fixture dir read-only");
325    }
326
327    #[cfg(windows)]
328    fn lock_dir_unwritable(dir: &Path) {
329        let out = std::process::Command::new("icacls")
330            .args([
331                dir.to_str().expect("utf8 path"),
332                "/deny",
333                "*S-1-1-0:(WD,AD)",
334            ])
335            .output()
336            .expect("spawn icacls /deny");
337        assert!(
338            out.status.success(),
339            "icacls /deny failed: {}",
340            String::from_utf8_lossy(&out.stderr)
341        );
342    }
343
344    /// Removes the Everyone-deny ACE on drop, so the fixture unlocks the dir BEFORE `TempDir`'s own
345    /// `Drop` tries to remove it — declared after `temp` in the test fn, so it drops first (reverse
346    /// declaration order).
347    #[cfg(windows)]
348    struct WindowsAclUnlock(PathBuf);
349
350    #[cfg(windows)]
351    impl Drop for WindowsAclUnlock {
352        fn drop(&mut self) {
353            let _ = std::process::Command::new("icacls")
354                .args([self.0.to_str().unwrap_or_default(), "/remove:d", "*S-1-1-0"])
355                .output();
356        }
357    }
358
359    #[test]
360    fn existing_but_unwritable_dir_fails_the_writability_probe() {
361        let temp = tempfile::tempdir().expect("tempdir");
362        let dir = temp.path().join("locked");
363        std::fs::create_dir_all(&dir).expect("create fixture dir");
364
365        lock_dir_unwritable(&dir);
366        #[cfg(windows)]
367        let _unlock = WindowsAclUnlock(dir.clone());
368
369        // Precondition guard (§2.2): a fixture production cannot reach proves nothing. If the lock
370        // didn't bite (privileged account, or icacls unavailable), skip rather than assert-pass.
371        if std::fs::File::create(dir.join("canary")).is_ok() {
372            eprintln!("skipped: cannot build an unwritable dir on this account");
373            return;
374        }
375
376        assert!(!dir_is_writable(&dir));
377    }
378
379    #[test]
380    fn fresh_writable_dir_probe_succeeds_and_leaves_no_residue() {
381        let temp = tempfile::tempdir().expect("tempdir");
382        let dir = temp.path().join("writable");
383
384        assert!(dir_is_writable(&dir));
385        let residue = std::fs::read_dir(&dir).expect("read_dir").count();
386        assert_eq!(residue, 0, "writability probe left a file behind");
387    }
388}