1#![cfg(unix)]
2use super::*;
3
4#[cfg(unix)]
15pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
16 let boot_guard = Some(acquire_daemon_boot_guard()?);
17 run_daemon_with_boot_guard_inner(
18 dispatcher,
19 boot_guard,
20 false,
21 DaemonOptions::default(),
22 |_| DaemonStartupReport::default(),
23 )
24 .await
25}
26
27#[cfg(all(unix, any(test, feature = "fault-injection")))]
42#[doc(hidden)]
43pub async fn run_daemon_in_process_test<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
44 let boot_guard = Some(acquire_daemon_boot_guard()?);
45 run_daemon_with_boot_guard_inner(
46 dispatcher,
47 boot_guard,
48 true,
49 DaemonOptions::default(),
50 |_| DaemonStartupReport::default(),
51 )
52 .await
53}
54
55#[cfg(unix)]
56#[derive(Clone, Copy, PartialEq, Eq)]
57pub(super) enum RendezvousPathRole<'a> {
58 Socket,
59 PidFile,
60 Store(&'a std::path::Path),
61}
62
63#[cfg(unix)]
64impl RendezvousPathRole<'_> {
65 pub(super) fn refusal(
66 self,
67 rendezvous: std::fmt::Arguments<'_>,
68 store: std::fmt::Arguments<'_>,
69 ) -> anyhow::Error {
70 match self {
71 Self::Store(db_path) => anyhow::anyhow!(
72 "refusing to open events database {}: {store}. Check the offending directory's \
73 ownership and permissions (remove group/other write access), or move the store \
74 to a location trusted end to end. Only the first offending component is \
75 reported; further untrusted ancestors or components may remain.",
76 db_path.display()
77 ),
78 Self::Socket | Self::PidFile => anyhow::anyhow!("{rendezvous}"),
79 }
80 }
81
82 pub(super) fn env_name(self) -> &'static str {
83 match self {
84 Self::Socket => SOCKET_PATH_ENV,
85 Self::PidFile => PID_PATH_ENV,
86 Self::Store(_) => "events database",
87 }
88 }
89
90 pub(super) fn directory_name(self) -> &'static str {
91 match self {
92 Self::Socket => "socket directory",
93 Self::PidFile => "PID-file directory",
94 Self::Store(_) => "store directory",
95 }
96 }
97
98 pub(super) fn path_name(self) -> &'static str {
99 match self {
100 Self::Socket => "socket path",
101 Self::PidFile => "PID-file path",
102 Self::Store(_) => "store path",
103 }
104 }
105
106 pub(super) fn path_component_name(self) -> &'static str {
107 match self {
108 Self::Socket => "socket-path",
109 Self::PidFile => "PID-file-path",
110 Self::Store(_) => "store-path",
111 }
112 }
113}
114
115#[cfg(unix)]
138pub(crate) fn ensure_socket_dir_is_trusted(parent: &std::path::Path) -> anyhow::Result<()> {
139 let daemon_euid = unsafe { libc::geteuid() } as u32;
141 ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::Socket, daemon_euid, true)
142}
143
144#[cfg(unix)]
146pub(crate) fn ensure_store_dir_is_trusted(
147 parent: &std::path::Path,
148 db_path: &std::path::Path,
149) -> anyhow::Result<()> {
150 let daemon_euid = unsafe { libc::geteuid() } as u32;
152 ensure_rendezvous_dir_is_trusted(
153 parent,
154 RendezvousPathRole::Store(db_path),
155 daemon_euid,
156 true,
157 )
158}
159
160#[cfg(unix)]
165pub fn ensure_pid_file_dir_is_trusted(pid_file: &std::path::Path) -> anyhow::Result<()> {
166 let parent = pid_file
167 .parent()
168 .filter(|parent| !parent.as_os_str().is_empty())
169 .unwrap_or_else(|| std::path::Path::new("."));
170 let daemon_euid = unsafe { libc::geteuid() } as u32;
172 ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::PidFile, daemon_euid, false)
173}
174
175#[cfg(unix)]
176pub(super) fn ensure_rendezvous_dir_is_trusted(
177 parent: &std::path::Path,
178 role: RendezvousPathRole<'_>,
179 daemon_euid: u32,
180 repair_owned_default: bool,
181) -> anyhow::Result<()> {
182 let env_name = role.env_name();
183 let directory_name = role.directory_name();
184
185 if repair_owned_default && parent == khive_dir() {
186 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
187 role.refusal(
188 format_args!(
189 "refusing to start: cannot chmod 0700 {}: {e}. The khive directory must be \
190 owner-only as the {directory_name} for {env_name}; it is part of the \
191 same-uid guarantee this daemon enforces.",
192 parent.display()
193 ),
194 format_args!(
195 "cannot chmod 0700 store directory {}: {e}",
196 parent.display()
197 ),
198 )
199 })?;
200 return ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role);
201 }
202
203 let meta = std::fs::metadata(parent).map_err(|e| {
206 role.refusal(
207 format_args!(
208 "refusing to start: cannot stat {directory_name} {} for {env_name}: {e}. \
209 It gates rendezvous-path safety, and unreadable metadata is not a passing state.",
210 parent.display()
211 ),
212 format_args!(
213 "cannot stat store directory {}: {e}; unreadable metadata is not a passing state",
214 parent.display()
215 ),
216 )
217 })?;
218
219 use std::os::unix::fs::MetadataExt;
220 let owner = meta.uid();
221 if owner != daemon_euid && owner != 0 {
222 return Err(role.refusal(
223 format_args!(
224 "refusing to start: {directory_name} {} for {env_name} is owned by uid {owner}, \
225 not this daemon's uid ({daemon_euid}) or root. A directory owner can replace \
226 the rendezvous path regardless of mode bits. Point {env_name} at a directory \
227 you own, or unset it for the default.",
228 parent.display()
229 ),
230 format_args!(
231 "store directory {} is owned by uid {owner}, not this process's uid \
232 ({daemon_euid}) or root; its owner can replace the database path",
233 parent.display()
234 ),
235 ));
236 }
237
238 let mode = meta.permissions().mode();
239 if mode & 0o022 != 0 {
240 return Err(role.refusal(
241 format_args!(
242 "refusing to start: {directory_name} {} for {env_name} is mode {:04o} — writable \
243 by group or other, so another local user could replace the rendezvous path. \
244 Use a directory only you can write, or unset {env_name} for the default. \
245 This daemon is not changing the permissions of a directory it does not own.",
246 parent.display(),
247 mode & 0o7777
248 ),
249 format_args!(
250 "store directory {} is mode {:04o}, writable by group or other, so another local \
251 user could replace the database path",
252 parent.display(),
253 mode & 0o7777
254 ),
255 ));
256 }
257
258 ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role)
259}
260
261#[cfg(all(unix, test))]
264pub(super) fn ensure_socket_path_is_swap_resistant(
265 parent: &std::path::Path,
266 daemon_euid: u32,
267) -> anyhow::Result<()> {
268 ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, RendezvousPathRole::Socket)
269}
270
271#[cfg(unix)]
296pub(super) fn ensure_rendezvous_path_is_swap_resistant(
297 parent: &std::path::Path,
298 daemon_euid: u32,
299 role: RendezvousPathRole<'_>,
300) -> anyhow::Result<()> {
301 use std::os::unix::fs::MetadataExt;
302
303 let env_name = role.env_name();
304 let directory_name = role.directory_name();
305 let path_name = role.path_name();
306 let component_name = role.path_component_name();
307
308 let absolute = if parent.is_absolute() {
309 parent.to_path_buf()
310 } else {
311 std::env::current_dir()
312 .map_err(|e| {
313 role.refusal(
314 format_args!(
315 "refusing to start: cannot resolve the working directory to absolutize \
316 {directory_name} {} for {env_name}: {e}.",
317 parent.display()
318 ),
319 format_args!(
320 "cannot resolve the working directory for store directory {}: {e}",
321 parent.display()
322 ),
323 )
324 })?
325 .join(parent)
326 };
327
328 fn push_components(stack: &mut Vec<std::ffi::OsString>, path: &std::path::Path) {
329 let components: Vec<_> = path
330 .components()
331 .map(|c| c.as_os_str().to_os_string())
332 .collect();
333 stack.extend(components.into_iter().rev());
334 }
335
336 let mut stack: Vec<std::ffi::OsString> = Vec::new();
337 push_components(&mut stack, &absolute);
338 let mut resolved = std::path::PathBuf::new();
339 let mut symlinks_followed = 0u32;
340
341 while let Some(component) = stack.pop() {
342 if component == "/" {
343 resolved = std::path::PathBuf::from("/");
344 continue;
345 }
346 if component == "." {
347 continue;
348 }
349 if component == ".." {
350 resolved.pop();
351 continue;
352 }
353 let candidate = resolved.join(&component);
354 let meta = std::fs::symlink_metadata(&candidate).map_err(|e| {
355 role.refusal(
356 format_args!(
357 "refusing to start: cannot stat {component_name} component {} for {env_name}: \
358 {e}. An unreadable component is not a passing one.",
359 candidate.display()
360 ),
361 format_args!(
362 "cannot stat store-path component {}: {e}; unreadable metadata is not a \
363 passing state",
364 candidate.display()
365 ),
366 )
367 })?;
368 let owner = meta.uid();
369
370 if meta.file_type().is_symlink() {
371 symlinks_followed += 1;
372 if symlinks_followed > 40 {
373 return Err(role.refusal(
374 format_args!(
375 "refusing to start: {path_name} for {env_name} resolves through more than \
376 40 symlinks at {} — treating this as a loop.",
377 candidate.display()
378 ),
379 format_args!(
380 "store path resolves through more than 40 symlinks at {}; treating this as \
381 a loop",
382 candidate.display()
383 ),
384 ));
385 }
386 if owner != daemon_euid && owner != 0 {
387 return Err(role.refusal(
388 format_args!(
389 "refusing to start: {component_name} symlink component {} for {env_name} \
390 is owned by uid {owner}, not this daemon's uid ({daemon_euid}) or root — \
391 its owner could retarget it after this check and re-root the {path_name}. \
392 Point {env_name} somewhere trusted end to end, or unset it for the default.",
393 candidate.display()
394 ),
395 format_args!(
396 "store-path symlink component {} is owned by uid {owner}, not this \
397 process's uid ({daemon_euid}) or root; its owner could retarget the \
398 database path",
399 candidate.display()
400 ),
401 ));
402 }
403 let target = std::fs::read_link(&candidate).map_err(|e| {
404 role.refusal(
405 format_args!(
406 "refusing to start: cannot read {component_name} symlink component {} \
407 for {env_name}: {e}.",
408 candidate.display()
409 ),
410 format_args!(
411 "cannot read store-path symlink component {}: {e}",
412 candidate.display()
413 ),
414 )
415 })?;
416 push_components(&mut stack, &target);
417 continue;
418 }
419
420 if meta.is_dir() {
421 let mode = meta.permissions().mode();
422 let sticky = mode & 0o1000 != 0;
423 if owner != daemon_euid && owner != 0 {
424 return Err(role.refusal(
425 format_args!(
426 "refusing to start: {component_name} ancestor {} for {env_name} is owned by \
427 uid {owner}, not this daemon's uid ({daemon_euid}) or root — its owner \
428 could rename the next path component and re-root the {path_name}. Point \
429 {env_name} somewhere trusted end to end, or unset it for the default.",
430 candidate.display()
431 ),
432 format_args!(
433 "store-path ancestor {} is owned by uid {owner}, not this process's uid \
434 ({daemon_euid}) or root; its owner could rename the next path component",
435 candidate.display()
436 ),
437 ));
438 }
439 if mode & 0o022 != 0 && !sticky {
440 return Err(role.refusal(
441 format_args!(
442 "refusing to start: {component_name} ancestor {} for {env_name} is mode \
443 {:04o} — writable by group or other without the sticky bit, so another \
444 local user could rename the next path component and re-root the {path_name}. \
445 Point {env_name} somewhere trusted end to end, or unset it for the default.",
446 candidate.display(),
447 mode & 0o7777
448 ),
449 format_args!(
450 "store-path ancestor {} is mode {:04o}, writable by group or other without \
451 the sticky bit, so another local user could rename the next path \
452 component",
453 candidate.display(), mode & 0o7777
454 ),
455 ));
456 }
457 resolved = candidate;
458 continue;
459 }
460
461 return Err(role.refusal(
462 format_args!(
463 "refusing to start: {component_name} component {} for {env_name} is neither a \
464 directory nor a symlink — the {path_name} cannot traverse it.",
465 candidate.display()
466 ),
467 format_args!(
468 "store-path component {} is neither a directory nor a symlink; the database path \
469 cannot traverse it",
470 candidate.display()
471 ),
472 ));
473 }
474
475 Ok(())
476}