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}