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//! probe, so every branch is table-testable without touching the real filesystem or environment.
15//!
16//! **Operator read on Windows (#728).** The machine root lives under `%ProgramData%\DigNetwork`,
17//! which dig-installer #715 locks to a protected, non-inheriting DACL of `{SYSTEM:F, Administrators:F}`
18//! (`icacls /inheritance:r`). The `logs\<service>` subtree therefore inherits NO non-admin read from
19//! that root. To keep logs operator-readable (SPEC §3), dig-logging follows the #715 adopter rule — a
20//! child needing non-admin read sets its OWN explicit ACE — and grants `BUILTIN\Users` a read+execute
21//! ACE on the machine service dir. This never loosens the #715 root DACL (a sibling subtree gets its
22//! own ACE; the root is untouched). Applied ONLY to the machine-root branch: the `DIG_LOG_DIR` override
23//! and the per-user dev fallback are caller-chosen / already user-owned and are left alone.
24
25use std::path::PathBuf;
26
27/// The env var that overrides the log ROOT outright (tests, custom deploys).
28pub const ENV_LOG_DIR: &str = "DIG_LOG_DIR";
29
30/// The `BUILTIN\Users` group SID — locale-independent, granted operator read on the machine log dir
31/// (#728). A well-known SID (not a localized name) so the `icacls` grant works on any Windows locale.
32const SID_USERS: &str = "S-1-5-32-545";
33
34/// Which resolution branch produced a log directory (SPEC §3). Distinguishes the machine root — the
35/// only branch under the #715-locked `%ProgramData%\DigNetwork` tree, and thus the only one needing an
36/// explicit operator-read ACE — from the caller-owned override and dev-fallback branches.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum LogDirSource {
39    /// The `DIG_LOG_DIR` override (a caller-chosen root).
40    Override,
41    /// The per-OS machine root (privileged run) — under the #715-locked root on Windows.
42    MachineRoot,
43    /// The per-user dev fallback (unprivileged run) — already owned by the running user.
44    DevFallback,
45}
46
47/// A resolved log directory plus the branch that produced it (see [`LogDirSource`]).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResolvedLogDir {
50    /// The service log directory (`<root>/<service>`).
51    pub path: PathBuf,
52    /// The resolution branch that chose `path`.
53    pub source: LogDirSource,
54}
55
56/// Resolve the log directory for `service` from an injected env-getter and a dir-creatable probe.
57///
58/// `get` reads an env var (`None`/blank = unset). `can_create` answers "can this exact directory be
59/// created and written?" — the caller wires it to the real filesystem in [`log_dir`], and tests
60/// inject a closure to exercise the machine-root vs dev-fallback branch deterministically.
61pub fn resolve_log_dir<G, C>(service: &str, get: G, can_create: C) -> PathBuf
62where
63    G: Fn(&str) -> Option<String>,
64    C: Fn(&std::path::Path) -> bool,
65{
66    resolve_log_dir_detailed(service, get, can_create).path
67}
68
69/// Resolve the log directory AND the branch that produced it (see [`resolve_log_dir`] for precedence).
70///
71/// Callers that must know whether the machine root was chosen — the only branch under the
72/// #715-locked Windows root — use this to decide whether to apply the operator-read ACE (#728).
73pub fn resolve_log_dir_detailed<G, C>(service: &str, get: G, can_create: C) -> ResolvedLogDir
74where
75    G: Fn(&str) -> Option<String>,
76    C: Fn(&std::path::Path) -> bool,
77{
78    let read = |key: &str| {
79        get(key)
80            .map(|value| value.trim().to_string())
81            .filter(|value| !value.is_empty())
82    };
83
84    // 1. Explicit override wins unconditionally.
85    if let Some(root) = read(ENV_LOG_DIR) {
86        return ResolvedLogDir {
87            path: PathBuf::from(root).join(service),
88            source: LogDirSource::Override,
89        };
90    }
91
92    // 2. Machine root when its service dir is creatable; 3. else the per-user dev fallback.
93    let machine = machine_root(&read).join(service);
94    if can_create(&machine) {
95        return ResolvedLogDir {
96            path: machine,
97            source: LogDirSource::MachineRoot,
98        };
99    }
100    ResolvedLogDir {
101        path: dev_root(&read).join(service),
102        source: LogDirSource::DevFallback,
103    }
104}
105
106/// The resolved service log directory for this process, wiring the real environment + a filesystem
107/// creatable-probe into [`resolve_log_dir_detailed`]. When the machine-root branch is taken on Windows,
108/// grants operators (`BUILTIN\Users`) read on the freshly-created dir (#728) — see the module docs.
109pub fn log_dir(service: &str) -> PathBuf {
110    let resolved = resolve_log_dir_detailed(
111        service,
112        |key| std::env::var(key).ok(),
113        |path| std::fs::create_dir_all(path).is_ok(),
114    );
115
116    #[cfg(windows)]
117    if resolved.source == LogDirSource::MachineRoot {
118        // Best-effort: a failed grant must never stop the service from logging. The dir already
119        // exists (the creatable-probe made it); we only relax read for operators.
120        grant_operator_read(&resolved.path);
121    }
122
123    resolved.path
124}
125
126/// The `icacls` argv that grants `BUILTIN\Users` a read+execute ACE, inheritable to child files/dirs,
127/// on `dir` (#728). By SID so it is locale-independent; `/grant:r` ADDS the ACE without replacing the
128/// DACL, so the inherited `{SYSTEM,Admins}` full-control from #715 (if any survives) stays intact.
129/// Pure, so the exact grant is unit-tested without touching the filesystem.
130pub fn windows_operator_read_args(dir: &str) -> Vec<String> {
131    vec![
132        dir.to_string(),
133        "/grant:r".to_string(),
134        format!("*{SID_USERS}:(OI)(CI)RX"),
135        "/T".to_string(),
136        "/C".to_string(),
137        "/Q".to_string(),
138    ]
139}
140
141/// Grant operators (`BUILTIN\Users`) read on the machine log dir via `icacls` (#728). Best-effort:
142/// any failure is swallowed, because losing operator read is a diagnostic inconvenience, never a
143/// reason to stop the service logging.
144#[cfg(windows)]
145fn grant_operator_read(dir: &std::path::Path) {
146    let Some(dir) = dir.to_str() else { return };
147    let _ = std::process::Command::new("icacls")
148        .args(windows_operator_read_args(dir))
149        .output();
150}
151
152/// The machine-wide log root (SPEC §3): `%PROGRAMDATA%\DigNetwork\logs` on Windows,
153/// `/Library/Logs/DigNetwork` on macOS, `/var/log/dig` on Linux.
154#[cfg(windows)]
155fn machine_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
156    let base = read("ProgramData").unwrap_or_else(|| r"C:\ProgramData".to_string());
157    PathBuf::from(base).join("DigNetwork").join("logs")
158}
159
160#[cfg(target_os = "macos")]
161fn machine_root<R: Fn(&str) -> Option<String>>(_read: &R) -> PathBuf {
162    PathBuf::from("/Library/Logs/DigNetwork")
163}
164
165#[cfg(all(unix, not(target_os = "macos")))]
166fn machine_root<R: Fn(&str) -> Option<String>>(_read: &R) -> PathBuf {
167    PathBuf::from("/var/log/dig")
168}
169
170/// The per-user dev-fallback log root (SPEC §3): `%LOCALAPPDATA%\DigNetwork\logs` on Windows,
171/// `~/Library/Logs/DigNetwork` on macOS, `${XDG_STATE_HOME:-~/.local/state}/dig/logs` on Linux.
172#[cfg(windows)]
173fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
174    let base = read("LOCALAPPDATA")
175        .or_else(|| read("ProgramData"))
176        .unwrap_or_else(|| r"C:\ProgramData".to_string());
177    PathBuf::from(base).join("DigNetwork").join("logs")
178}
179
180#[cfg(target_os = "macos")]
181fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
182    let home = read("HOME").unwrap_or_else(|| "/tmp".to_string());
183    PathBuf::from(home)
184        .join("Library")
185        .join("Logs")
186        .join("DigNetwork")
187}
188
189#[cfg(all(unix, not(target_os = "macos")))]
190fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
191    if let Some(state) = read("XDG_STATE_HOME") {
192        return PathBuf::from(state).join("dig").join("logs");
193    }
194    let home = read("HOME").unwrap_or_else(|| "/tmp".to_string());
195    PathBuf::from(home)
196        .join(".local")
197        .join("state")
198        .join("dig")
199        .join("logs")
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::collections::HashMap;
206    use std::path::Path;
207
208    /// Build an env-getter over a fixed map, so a test names exactly the vars it sets.
209    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
210        let map: HashMap<String, String> = pairs
211            .iter()
212            .map(|(k, v)| (k.to_string(), v.to_string()))
213            .collect();
214        move |key| map.get(key).cloned()
215    }
216
217    #[test]
218    fn override_wins_and_joins_service() {
219        let dir = resolve_log_dir("dig-node", env(&[(ENV_LOG_DIR, "/custom/root")]), |_| true);
220        assert_eq!(dir, Path::new("/custom/root").join("dig-node"));
221    }
222
223    #[test]
224    fn blank_override_is_ignored() {
225        // A blank override must NOT shadow the machine root (a common empty-env-var footgun).
226        let dir = resolve_log_dir("dig-dns", env(&[(ENV_LOG_DIR, "   ")]), |_| true);
227        assert!(dir.ends_with(Path::new("dig-dns")));
228        assert!(!dir.starts_with("/custom"));
229    }
230
231    #[test]
232    fn machine_root_used_when_creatable() {
233        let dir = resolve_log_dir("dig-updater", env(&[]), |_| true);
234        assert!(dir.ends_with(Path::new("dig-updater")));
235        #[cfg(all(unix, not(target_os = "macos")))]
236        assert_eq!(dir, Path::new("/var/log/dig/dig-updater"));
237        #[cfg(target_os = "macos")]
238        assert_eq!(dir, Path::new("/Library/Logs/DigNetwork/dig-updater"));
239    }
240
241    #[test]
242    fn dev_fallback_when_machine_root_not_creatable() {
243        // Simulate an unprivileged run: the machine root cannot be created, so we fall back.
244        let dir = resolve_log_dir(
245            "dig-node",
246            env(&[
247                ("HOME", "/home/dev"),
248                ("XDG_STATE_HOME", "/home/dev/.state"),
249                ("LOCALAPPDATA", r"C:\Users\dev\AppData\Local"),
250            ]),
251            |path: &Path| path.to_string_lossy().contains("dev"),
252        );
253        assert!(dir.ends_with(Path::new("dig-node")));
254        #[cfg(all(unix, not(target_os = "macos")))]
255        assert_eq!(dir, Path::new("/home/dev/.state/dig/logs/dig-node"));
256    }
257
258    #[cfg(all(unix, not(target_os = "macos")))]
259    #[test]
260    fn linux_dev_fallback_without_xdg_uses_local_state() {
261        let dir = resolve_log_dir("dig-dns", env(&[("HOME", "/home/dev")]), |_| false);
262        assert_eq!(dir, Path::new("/home/dev/.local/state/dig/logs/dig-dns"));
263    }
264
265    #[test]
266    fn override_reports_override_source() {
267        let resolved =
268            resolve_log_dir_detailed("dig-node", env(&[(ENV_LOG_DIR, "/custom")]), |_| true);
269        assert_eq!(resolved.source, LogDirSource::Override);
270    }
271
272    #[test]
273    fn creatable_machine_root_reports_machine_source() {
274        // The machine root is the ONLY branch under the #715-locked Windows root, so it is the only
275        // one that later earns the operator-read ACE (#728).
276        let resolved = resolve_log_dir_detailed("dig-node", env(&[]), |_| true);
277        assert_eq!(resolved.source, LogDirSource::MachineRoot);
278    }
279
280    #[test]
281    fn uncreatable_machine_root_reports_dev_fallback_source() {
282        let resolved =
283            resolve_log_dir_detailed("dig-node", env(&[("HOME", "/home/dev")]), |_| false);
284        assert_eq!(resolved.source, LogDirSource::DevFallback);
285    }
286
287    #[test]
288    fn operator_read_grant_targets_users_sid_read_execute_inheritable() {
289        // #728: a non-replacing (`/grant:r`) read+execute ACE for BUILTIN\Users by SID, inheritable
290        // to child files/dirs — so operators can read logs without loosening the #715 root DACL.
291        let args = windows_operator_read_args(r"C:\ProgramData\DigNetwork\logs\dig-node");
292        assert_eq!(args[0], r"C:\ProgramData\DigNetwork\logs\dig-node");
293        assert!(args.iter().any(|a| a == "/grant:r"));
294        assert!(args.iter().any(|a| a == "*S-1-5-32-545:(OI)(CI)RX"));
295        // Never a DACL-replacing flag — the inherited {SYSTEM,Admins} full-control must survive.
296        assert!(!args.iter().any(|a| a == "/inheritance:r" || a == "/reset"));
297    }
298}