use std::ffi::CString;
use std::fmt;
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf;
use nix::fcntl::Flock;
use crate::config::DaemonConfig;
use crate::error::DaemonizeError;
use crate::identity::ResolvedIdentity;
use crate::notify::NotifyPipe;
#[non_exhaustive]
pub struct DaemonContext {
config: DaemonConfig,
lockfile: Option<Flock<OwnedFd>>,
notify_pipe: Option<NotifyPipe>,
cleaned_up: bool,
privileges_dropped: bool,
}
impl fmt::Debug for DaemonContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
struct OptFmt<'a, T: fmt::Debug>(&'a Option<T>);
impl<T: fmt::Debug> fmt::Debug for OptFmt<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(v) => v.fmt(f),
None => f.write_str("none"),
}
}
}
f.debug_struct("DaemonContext")
.field("lockfile", &OptFmt(&self.lockfile.as_ref().map(|_| "held")))
.field(
"notify_pipe",
&OptFmt(&self.notify_pipe.as_ref().map(|_| "open")),
)
.field("pidfile", &OptFmt(&self.config.pidfile))
.field("lockfile_path", &OptFmt(&self.config.lockfile))
.field("stdout", &OptFmt(&self.config.stdout))
.field("stderr", &OptFmt(&self.config.stderr))
.field("user", &OptFmt(&self.config.user))
.field("group", &OptFmt(&self.config.group))
.field("cleanup_on_drop", &self.config.cleanup_on_drop)
.finish()
}
}
impl DaemonContext {
pub(crate) fn new(
config: &DaemonConfig,
lockfile: Option<Flock<OwnedFd>>,
notify_pipe: Option<NotifyPipe>,
) -> Self {
Self {
config: config.clone(),
lockfile,
notify_pipe,
cleaned_up: false,
privileges_dropped: false,
}
}
fn privileges_pending(&self) -> bool {
(self.config.user.is_some() || self.config.group.is_some()) && !self.privileges_dropped
}
pub fn lockfile_fd(&self) -> Option<BorrowedFd<'_>> {
self.lockfile.as_ref().map(|flock| flock.as_fd())
}
pub fn set_cleanup_on_drop(&mut self, cleanup: bool) {
self.config.cleanup_on_drop = cleanup;
}
pub fn cleanup(&mut self) {
if self.cleaned_up {
return;
}
self.cleaned_up = true;
if let Some(ref path) = self.config.pidfile {
let _ = std::fs::remove_file(path);
}
}
pub fn cleanup_on_term_signals(&self) -> Result<(), DaemonizeError> {
self.cleanup_on_signals(&[libc::SIGINT, libc::SIGTERM])
}
pub fn cleanup_on_signals(&self, signals: &[i32]) -> Result<(), DaemonizeError> {
let Some(ref pidfile) = self.config.pidfile else {
return Ok(());
};
if signals.is_empty() {
return Ok(());
}
let c_path = CString::new(pidfile.as_os_str().as_bytes()).map_err(|_| {
DaemonizeError::ValidationError("pidfile path contains NUL byte".into())
})?;
crate::unsafe_ops::install_pidfile_cleanup_signals(&c_path, signals).map_err(|e| {
DaemonizeError::ValidationError(format!("failed to install signal handler: {e}"))
})
}
pub fn chown_paths(&mut self) -> Result<(), DaemonizeError> {
if self.config.user.is_none() && self.config.group.is_none() {
return Ok(());
}
let identity =
ResolvedIdentity::resolve(self.config.user.as_deref(), self.config.group.as_deref())?;
let (owner, group) = identity.chown_ids();
let paths: Vec<&PathBuf> = [
&self.config.pidfile,
&self.config.lockfile,
&self.config.stdout,
&self.config.stderr,
]
.iter()
.filter_map(|p| p.as_ref())
.collect();
for path in paths {
if path.exists() {
nix::unistd::chown(path, owner, group)
.map_err(|e| DaemonizeError::ChownError(format!("{}: {e}", path.display())))?;
}
}
Ok(())
}
pub fn drop_privileges(&mut self) -> Result<(), DaemonizeError> {
if self.config.user.is_none() && self.config.group.is_none() {
self.privileges_dropped = true;
return Ok(());
}
let identity =
ResolvedIdentity::resolve(self.config.user.as_deref(), self.config.group.as_deref())?;
if let Some(info) = identity.user() {
crate::unsafe_ops::raw_initgroups(&info.cname()?, info.gid.as_raw())
.map_err(|e| DaemonizeError::PermissionDenied(format!("initgroups: {e}")))?;
}
if let Some(gid) = identity.effective_gid() {
nix::unistd::setgid(gid)
.map_err(|e| DaemonizeError::PermissionDenied(format!("setgid: {e}")))?;
}
if let Some(info) = identity.user() {
nix::unistd::setuid(info.uid)
.map_err(|e| DaemonizeError::PermissionDenied(format!("setuid: {e}")))?;
crate::unsafe_ops::raw_set_env_var("USER", &info.name);
crate::unsafe_ops::raw_set_env_var("HOME", &info.dir);
crate::unsafe_ops::raw_set_env_var("LOGNAME", &info.name);
}
self.privileges_dropped = true;
Ok(())
}
#[must_use = "the parent process blocks until notified; ignoring this Result may leave it waiting"]
pub fn notify_parent(&mut self) -> Result<(), DaemonizeError> {
if self.privileges_pending() {
return Err(DaemonizeError::PrivilegesNotDropped);
}
if let Some(pipe) = self.notify_pipe.take() {
pipe.signal_ready().map_err(DaemonizeError::NotifyFailed)?;
}
Ok(())
}
pub fn notify_parent_or_report(&mut self) {
if self.privileges_pending() {
self.report_error(&DaemonizeError::PrivilegesNotDropped);
}
if let Some(pipe) = self.notify_pipe.take() {
if let Err(e) = pipe.signal_ready() {
self.report_error(&DaemonizeError::NotifyFailed(e));
}
}
}
pub fn report_error(&mut self, err: &DaemonizeError) -> ! {
let code = self.cleanup_and_signal_error(err);
crate::unsafe_ops::raw_exit(code as i32)
}
fn cleanup_and_signal_error(&mut self, err: &DaemonizeError) -> u8 {
if self.config.cleanup_on_drop {
self.cleanup();
}
if let Some(pipe) = self.notify_pipe.take() {
pipe.signal_error(err);
}
err.exit_code()
}
pub fn report_error_msg(&mut self, code: u8, message: impl Into<String>) -> ! {
self.report_error(&DaemonizeError::application(code, message))
}
}
impl Drop for DaemonContext {
fn drop(&mut self) {
if let Some(pipe) = self.notify_pipe.take() {
pipe.signal_unnotified();
}
if self.config.cleanup_on_drop {
self.cleanup();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn make_pipe() -> (OwnedFd, OwnedFd) {
nix::unistd::pipe().unwrap()
}
fn read_pipe(rd: OwnedFd) -> Vec<u8> {
let mut buf = Vec::new();
let mut file = std::fs::File::from(rd);
file.read_to_end(&mut buf).unwrap();
buf
}
fn ctx(
config: &DaemonConfig,
lockfile: Option<Flock<OwnedFd>>,
notify_pipe: Option<NotifyPipe>,
) -> DaemonContext {
DaemonContext::new(config, lockfile, notify_pipe)
}
fn default_ctx() -> DaemonContext {
ctx(&DaemonConfig::new(), None, None)
}
#[test]
fn notify_parent_writes_success_byte() {
let (rd, wr) = make_pipe();
let mut ctx = ctx(&DaemonConfig::new(), None, Some(NotifyPipe::new(wr)));
ctx.notify_parent().unwrap();
assert_eq!(read_pipe(rd), vec![0x00]);
}
#[test]
fn notify_parent_or_report_writes_success_byte() {
let (rd, wr) = make_pipe();
let mut ctx = ctx(&DaemonConfig::new(), None, Some(NotifyPipe::new(wr)));
ctx.notify_parent_or_report(); assert_eq!(read_pipe(rd), vec![0x00]);
}
#[test]
fn privileges_pending_tracks_config_and_drop_state() {
let mut user_cfg = DaemonConfig::new();
user_cfg.user("nobody");
let mut group_cfg = DaemonConfig::new();
group_cfg.group("nogroup");
for cfg in [&user_cfg, &group_cfg] {
let mut c = ctx(cfg, None, None);
assert!(c.privileges_pending(), "configured user/group, not dropped");
c.privileges_dropped = true;
assert!(!c.privileges_pending(), "no longer pending once dropped");
}
assert!(!default_ctx().privileges_pending());
}
#[test]
fn notify_parent_refuses_when_privileges_pending() {
let (_rd, wr) = make_pipe();
let mut config = DaemonConfig::new();
config.user("nobody");
let mut ctx = ctx(&config, None, Some(NotifyPipe::new(wr)));
assert!(
matches!(
ctx.notify_parent(),
Err(DaemonizeError::PrivilegesNotDropped)
),
"must refuse to signal readiness while still privileged"
);
}
#[test]
fn notify_parent_succeeds_once_privileges_dropped() {
let (rd, wr) = make_pipe();
let mut config = DaemonConfig::new();
config.user("nobody");
let mut ctx = ctx(&config, None, Some(NotifyPipe::new(wr)));
ctx.privileges_dropped = true;
ctx.notify_parent().unwrap();
assert_eq!(read_pipe(rd), vec![0x00]);
}
#[test]
fn cleanup_on_signals_noop_without_pidfile() {
let dctx = default_ctx();
assert!(dctx.cleanup_on_signals(&[libc::SIGTERM]).is_ok());
assert!(dctx.cleanup_on_term_signals().is_ok());
let dir = tempfile::tempdir().unwrap();
let mut cfg = DaemonConfig::new();
cfg.pidfile(dir.path().join("x.pid"));
assert!(ctx(&cfg, None, None).cleanup_on_signals(&[]).is_ok());
}
#[test]
fn cleanup_on_signals_uncatchable_signal_rejected() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = DaemonConfig::new();
cfg.pidfile(dir.path().join("x.pid"));
assert!(matches!(
ctx(&cfg, None, None).cleanup_on_signals(&[libc::SIGKILL]),
Err(DaemonizeError::ValidationError(_))
));
}
#[test]
fn cleanup_on_signals_removes_pidfile_on_signal() {
const PIDFILE_ENV: &str = "__BLIVET_CLEANUP_PIDFILE";
if let Ok(path) = std::env::var(PIDFILE_ENV) {
std::fs::write(&path, "123").unwrap();
let mut cfg = DaemonConfig::new();
cfg.pidfile(&path);
let ctx = ctx(&cfg, None, None);
ctx.cleanup_on_signals(&[libc::SIGTERM]).unwrap();
nix::sys::signal::raise(nix::sys::signal::Signal::SIGTERM).unwrap();
std::thread::sleep(std::time::Duration::from_secs(5));
unreachable!("should have been killed by the re-raised SIGTERM");
}
use std::os::unix::process::ExitStatusExt;
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("daemon.pid");
let exe = std::env::current_exe().unwrap();
let status = std::process::Command::new(exe)
.arg("--exact")
.arg("context::tests::cleanup_on_signals_removes_pidfile_on_signal")
.arg("--nocapture")
.env(PIDFILE_ENV, &pidfile)
.status()
.unwrap();
assert_eq!(
status.signal(),
Some(libc::SIGTERM),
"child should terminate via the re-raised SIGTERM"
);
assert!(
!pidfile.exists(),
"handler should have removed the pidfile before re-raising"
);
}
#[test]
fn notify_parent_idempotent() {
let (_rd, wr) = make_pipe();
let mut ctx = ctx(&DaemonConfig::new(), None, Some(NotifyPipe::new(wr)));
ctx.notify_parent().unwrap();
ctx.notify_parent().unwrap();
}
#[test]
fn drop_writes_failure_when_not_notified() {
let (rd, wr) = make_pipe();
{
let _ctx = ctx(&DaemonConfig::new(), None, Some(NotifyPipe::new(wr)));
}
let buf = read_pipe(rd);
assert_eq!(buf[0], 1u8);
assert_eq!(
std::str::from_utf8(&buf[1..]).unwrap(),
"daemon exited without signaling readiness"
);
}
#[test]
fn report_error_removes_pidfile_before_signaling_parent() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("daemon.pid");
std::fs::write(&pidfile, "123").unwrap();
let (rd, wr) = make_pipe();
let mut cfg = DaemonConfig::new();
cfg.pidfile(&pidfile);
let mut ctx = ctx(&cfg, None, Some(NotifyPipe::new(wr)));
let code = ctx.cleanup_and_signal_error(&DaemonizeError::ExecFailed("boom".into()));
assert_eq!(code, 71, "ExecFailed maps to exit 71");
assert!(
!pidfile.exists(),
"pidfile must be removed before the parent is signaled"
);
let buf = read_pipe(rd);
assert_eq!(buf[0], 71, "error code byte signaled to parent");
assert_eq!(std::str::from_utf8(&buf[1..]).unwrap(), "exec failed: boom");
}
#[test]
fn report_error_respects_cleanup_on_drop_disabled() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("daemon.pid");
std::fs::write(&pidfile, "123").unwrap();
let (_rd, wr) = make_pipe();
let mut cfg = DaemonConfig::new();
cfg.pidfile(&pidfile).cleanup_on_drop(false);
let mut ctx = ctx(&cfg, None, Some(NotifyPipe::new(wr)));
ctx.cleanup_and_signal_error(&DaemonizeError::ExecFailed("boom".into()));
assert!(pidfile.exists(), "cleanup_on_drop=false keeps the pidfile");
}
#[test]
fn drop_no_write_after_notify() {
let (rd, wr) = make_pipe();
{
let mut ctx = ctx(&DaemonConfig::new(), None, Some(NotifyPipe::new(wr)));
ctx.notify_parent().unwrap();
}
assert_eq!(read_pipe(rd), vec![0x00]);
}
#[test]
fn debug_format() {
let ctx = default_ctx();
let debug = format!("{:?}", ctx);
assert!(
debug.contains("none"),
"all-None ctx should show 'none': {debug}"
);
assert!(
!debug.contains("Some"),
"should not contain 'Some': {debug}"
);
assert!(
!debug.contains("None"),
"should not contain 'None': {debug}"
);
}
#[test]
fn notify_parent_noop_without_pipe() {
let mut ctx = default_ctx();
assert!(ctx.notify_parent().is_ok());
}
#[test]
fn lockfile_fd_returns_some_with_lockfile() {
use nix::fcntl::{open, Flock, FlockArg, OFlag};
use nix::sys::stat::Mode;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.lock");
let fd = open(
&path,
OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_CLOEXEC,
Mode::from_bits_truncate(0o644),
)
.unwrap();
let flock = Flock::lock(fd, FlockArg::LockExclusiveNonblock).unwrap();
let ctx = ctx(&DaemonConfig::new(), Some(flock), None);
assert!(ctx.lockfile_fd().is_some());
drop(ctx);
}
#[test]
fn drop_releases_lockfile() {
use nix::fcntl::{open, Flock, FlockArg, OFlag};
use nix::sys::stat::Mode;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.lock");
let open_lockfile = || {
open(
&path,
OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_CLOEXEC,
Mode::from_bits_truncate(0o644),
)
.unwrap()
};
let flock = Flock::lock(open_lockfile(), FlockArg::LockExclusiveNonblock).unwrap();
let ctx = ctx(&DaemonConfig::new(), Some(flock), None);
let held = Flock::lock(open_lockfile(), FlockArg::LockExclusiveNonblock);
assert!(held.is_err(), "lock should be held while context is alive");
drop(held);
drop(ctx);
let reacquired = Flock::lock(open_lockfile(), FlockArg::LockExclusiveNonblock);
assert!(
reacquired.is_ok(),
"lock should be released after the context is dropped"
);
}
#[test]
fn lockfile_fd_returns_none_without_lockfile() {
let ctx = default_ctx();
assert!(ctx.lockfile_fd().is_none());
}
#[test]
fn drop_privileges_noop_without_user_or_group() {
let mut ctx = default_ctx();
assert!(ctx.drop_privileges().is_ok());
}
#[test]
fn chown_paths_noop_without_user_or_group() {
let mut ctx = default_ctx();
assert!(ctx.chown_paths().is_ok());
}
#[test]
fn drop_privileges_user_not_found() {
if std::env::var("CI").is_ok() {
return; }
let mut config = DaemonConfig::new();
config.user("nonexistent_daemonize_test_user_xyz");
let mut ctx = ctx(&config, None, None);
let result = ctx.drop_privileges();
assert!(matches!(
result,
Err(crate::DaemonizeError::UserNotFound(_))
));
}
#[test]
fn drop_privileges_group_not_found() {
if std::env::var("CI").is_ok() {
return; }
let mut config = DaemonConfig::new();
config.group("nonexistent_daemonize_test_group_xyz");
let mut ctx = ctx(&config, None, None);
let result = ctx.drop_privileges();
assert!(matches!(
result,
Err(crate::DaemonizeError::GroupNotFound(_))
));
}
#[test]
fn context_stores_config_fields() {
let mut config = DaemonConfig::new();
config
.pidfile("/var/run/test.pid")
.lockfile("/var/run/test.lock")
.stdout("/var/log/test.out")
.stderr("/var/log/test.err")
.user("nobody")
.group("nogroup");
let ctx = ctx(&config, None, None);
let debug = format!("{:?}", ctx);
assert!(debug.contains("test.pid"));
assert!(debug.contains("nobody"));
assert!(debug.contains("nogroup"));
}
fn pidfile_config(pidfile: impl Into<PathBuf>) -> DaemonConfig {
let mut config = DaemonConfig::new();
config.pidfile(pidfile).cleanup_on_drop(false);
config
}
#[test]
fn chown_paths_skips_nonexistent_files() {
let mut config = pidfile_config("/nonexistent_daemonize_test_xyz/test.pid");
config.user("root");
let mut ctx = ctx(&config, None, None);
assert!(ctx.chown_paths().is_ok());
}
#[test]
fn chown_paths_idempotent() {
let mut ctx = default_ctx();
assert!(ctx.chown_paths().is_ok());
assert!(ctx.chown_paths().is_ok());
}
#[test]
fn drop_privileges_idempotent_noop() {
let mut ctx = default_ctx();
assert!(ctx.drop_privileges().is_ok());
assert!(ctx.drop_privileges().is_ok());
}
#[test]
fn error_display_group_not_found() {
let err = crate::DaemonizeError::GroupNotFound("nobody".into());
assert_eq!(err.to_string(), "group not found: nobody");
}
#[test]
fn error_display_chown_error() {
let err = crate::DaemonizeError::ChownError("/tmp/foo: permission denied".into());
assert_eq!(err.to_string(), "chown error: /tmp/foo: permission denied");
}
#[test]
fn cleanup_removes_pidfile() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("test.pid");
std::fs::write(&pidfile, "12345\n").unwrap();
let mut ctx = ctx(&pidfile_config(&pidfile), None, None);
ctx.cleanup();
assert!(!pidfile.exists(), "pidfile should be removed after cleanup");
}
#[test]
fn cleanup_idempotent() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("test.pid");
std::fs::write(&pidfile, "12345\n").unwrap();
let mut ctx = ctx(&pidfile_config(&pidfile), None, None);
ctx.cleanup();
ctx.cleanup(); assert!(!pidfile.exists());
}
#[test]
fn cleanup_noop_without_pidfile() {
let mut config = DaemonConfig::new();
config.cleanup_on_drop(false);
let mut ctx = ctx(&config, None, None);
ctx.cleanup(); }
#[test]
fn cleanup_ignores_missing_pidfile() {
let mut ctx = ctx(&pidfile_config("/nonexistent_xyz/test.pid"), None, None);
ctx.cleanup(); }
#[test]
fn drop_cleans_up_when_configured() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("test.pid");
std::fs::write(&pidfile, "12345\n").unwrap();
{
let mut config = DaemonConfig::new();
config.pidfile(&pidfile); let _ctx = ctx(&config, None, None);
}
assert!(!pidfile.exists(), "pidfile should be removed on drop");
}
#[test]
fn drop_skips_cleanup_when_disabled() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("test.pid");
std::fs::write(&pidfile, "12345\n").unwrap();
{
let _ctx = ctx(&pidfile_config(&pidfile), None, None);
}
assert!(
pidfile.exists(),
"pidfile should survive drop when cleanup_on_drop=false"
);
}
#[test]
fn set_cleanup_on_drop_overrides_config() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("test.pid");
std::fs::write(&pidfile, "12345\n").unwrap();
{
let mut ctx = ctx(&pidfile_config(&pidfile), None, None);
ctx.set_cleanup_on_drop(true);
}
assert!(
!pidfile.exists(),
"pidfile should be removed after runtime override"
);
}
#[test]
fn cleanup_leaves_standalone_lockfile() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("test.pid");
let lockfile_path = dir.path().join("test.lock");
std::fs::write(&pidfile, "12345\n").unwrap();
std::fs::write(&lockfile_path, "").unwrap();
let mut config = pidfile_config(&pidfile);
config.lockfile(&lockfile_path);
let mut ctx = ctx(&config, None, None);
ctx.cleanup();
assert!(!pidfile.exists(), "pidfile should be removed");
assert!(
lockfile_path.exists(),
"standalone lockfile should be left on disk"
);
}
}