1use std::path::PathBuf;
27
28pub const ENV_LOG_DIR: &str = "DIG_LOG_DIR";
30
31const SID_USERS: &str = "S-1-5-32-545";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum LogDirSource {
40 Override,
42 MachineRoot,
44 DevFallback,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ResolvedLogDir {
51 pub path: PathBuf,
53 pub source: LogDirSource,
55}
56
57pub 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
70pub 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 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 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
107pub 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 grant_operator_read(&resolved.path);
119 }
120
121 resolved.path
122}
123
124fn 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
145pub 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#[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#[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#[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 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 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 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 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 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 assert!(!args.iter().any(|a| a == "/inheritance:r" || a == "/reset"));
316 }
317
318 #[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 #[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 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}