use std::fmt;
use std::io::{self, Write};
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::path::PathBuf;
use nix::fcntl::Flock;
use crate::error::DaemonizeError;
#[non_exhaustive]
pub struct DaemonContext {
lockfile: Option<Flock<OwnedFd>>,
notify_pipe: Option<OwnedFd>,
pidfile: Option<PathBuf>,
lockfile_path: Option<PathBuf>,
stdout: Option<PathBuf>,
stderr: Option<PathBuf>,
user: Option<String>,
group: Option<String>,
cleanup_on_drop: bool,
cleaned_up: 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.pidfile))
.field("lockfile_path", &OptFmt(&self.lockfile_path))
.field("stdout", &OptFmt(&self.stdout))
.field("stderr", &OptFmt(&self.stderr))
.field("user", &OptFmt(&self.user))
.field("group", &OptFmt(&self.group))
.field("cleanup_on_drop", &self.cleanup_on_drop)
.finish()
}
}
impl DaemonContext {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
lockfile: Option<Flock<OwnedFd>>,
notify_pipe: Option<OwnedFd>,
pidfile: Option<PathBuf>,
lockfile_path: Option<PathBuf>,
stdout: Option<PathBuf>,
stderr: Option<PathBuf>,
user: Option<String>,
group: Option<String>,
cleanup_on_drop: bool,
) -> Self {
Self {
lockfile,
notify_pipe,
pidfile,
lockfile_path,
stdout,
stderr,
user,
group,
cleanup_on_drop,
cleaned_up: false,
}
}
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.cleanup_on_drop = cleanup;
}
pub fn cleanup(&mut self) {
if self.cleaned_up {
return;
}
self.cleaned_up = true;
if let Some(ref path) = self.pidfile {
let _ = std::fs::remove_file(path);
}
}
pub fn chown_paths(&mut self) -> Result<(), DaemonizeError> {
if self.user.is_none() && self.group.is_none() {
return Ok(());
}
let (uid, gid) = resolve_uid_gid(self.user.as_deref(), self.group.as_deref())?;
let owner = Some(nix::unistd::Uid::from_raw(uid));
let group = Some(nix::unistd::Gid::from_raw(gid));
let paths: Vec<&PathBuf> = [
&self.pidfile,
&self.lockfile_path,
&self.stdout,
&self.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> {
use std::ffi::CString;
if self.user.is_none() && self.group.is_none() {
return Ok(());
}
let user_info = match self.user.as_deref() {
Some(spec) => Some(resolve_user(spec)?),
None => None,
};
let group_gid = match self.group.as_deref() {
Some(spec) => Some(resolve_group_gid(spec)?),
None => None,
};
if let Some(ref info) = user_info {
let cname = CString::new(info.name.as_str())
.map_err(|e| DaemonizeError::UserNotFound(format!("invalid username: {e}")))?;
crate::unsafe_ops::raw_initgroups(&cname, info.gid.as_raw())
.map_err(|e| DaemonizeError::PermissionDenied(format!("initgroups: {e}")))?;
}
let effective_gid = group_gid.or(user_info.as_ref().map(|u| u.gid));
if let Some(gid) = effective_gid {
nix::unistd::setgid(gid)
.map_err(|e| DaemonizeError::PermissionDenied(format!("setgid: {e}")))?;
}
if let Some(ref info) = user_info {
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);
}
Ok(())
}
#[must_use = "the parent process blocks until notified; ignoring this Result may leave it waiting"]
pub fn notify_parent(&mut self) -> Result<(), io::Error> {
if let Some(fd) = self.notify_pipe.take() {
let mut file = io::BufWriter::new(std::fs::File::from(fd));
file.write_all(&[0x00])?;
file.flush()?;
}
Ok(())
}
pub fn report_error(&mut self, err: &DaemonizeError) -> ! {
if let Some(fd) = self.notify_pipe.take() {
let mut file = io::BufWriter::new(std::fs::File::from(fd));
let msg = err.to_string();
let code = err.exit_code();
let mut buf = Vec::with_capacity(1 + msg.len());
buf.push(code);
buf.extend_from_slice(msg.as_bytes());
let _ = file.write_all(&buf);
let _ = file.flush();
}
crate::unsafe_ops::raw_exit(err.exit_code() as i32)
}
}
impl Drop for DaemonContext {
fn drop(&mut self) {
if let Some(fd) = self.notify_pipe.take() {
let mut file = io::BufWriter::new(std::fs::File::from(fd));
let msg = b"daemon exited without signaling readiness";
let mut buf = Vec::with_capacity(1 + msg.len());
buf.push(1u8);
buf.extend_from_slice(msg);
let _ = file.write_all(&buf);
let _ = file.flush();
}
if self.cleanup_on_drop {
self.cleanup();
}
}
}
struct ResolvedUser {
name: String,
uid: nix::unistd::Uid,
gid: nix::unistd::Gid,
dir: std::path::PathBuf,
}
fn resolve_user(spec: &str) -> Result<ResolvedUser, DaemonizeError> {
use nix::unistd::User;
if let Ok(uid_num) = spec.parse::<u32>() {
let uid = nix::unistd::Uid::from_raw(uid_num);
let user = User::from_uid(uid)
.map_err(|e| DaemonizeError::UserNotFound(format!("getpwuid({uid_num}): {e}")))?
.ok_or_else(|| DaemonizeError::UserNotFound(format!("uid {uid_num}")))?;
Ok(ResolvedUser {
name: user.name,
uid: user.uid,
gid: user.gid,
dir: user.dir,
})
} else {
let user = User::from_name(spec)
.map_err(|e| DaemonizeError::UserNotFound(format!("getpwnam({spec}): {e}")))?
.ok_or_else(|| DaemonizeError::UserNotFound(spec.to_string()))?;
Ok(ResolvedUser {
name: user.name,
uid: user.uid,
gid: user.gid,
dir: user.dir,
})
}
}
fn resolve_group_gid(spec: &str) -> Result<nix::unistd::Gid, DaemonizeError> {
use nix::unistd::Group;
if let Ok(gid_num) = spec.parse::<u32>() {
Ok(nix::unistd::Gid::from_raw(gid_num))
} else {
let group = Group::from_name(spec)
.map_err(|e| DaemonizeError::GroupNotFound(format!("getgrnam({spec}): {e}")))?
.ok_or_else(|| DaemonizeError::GroupNotFound(spec.to_string()))?;
Ok(group.gid)
}
}
fn resolve_uid_gid(
user: Option<&str>,
group: Option<&str>,
) -> Result<(libc::uid_t, libc::gid_t), DaemonizeError> {
let resolved_user = match user {
Some(spec) => Some(resolve_user(spec)?),
None => None,
};
let uid = resolved_user.as_ref().map_or(u32::MAX, |u| u.uid.as_raw());
let gid = match group {
Some(spec) => resolve_group_gid(spec)?.as_raw(),
None => resolved_user.as_ref().map_or(u32::MAX, |u| u.gid.as_raw()),
};
Ok((uid, gid))
}
#[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
}
struct TestCtx {
lockfile: Option<Flock<OwnedFd>>,
notify_pipe: Option<OwnedFd>,
pidfile: Option<PathBuf>,
lockfile_path: Option<PathBuf>,
stdout: Option<PathBuf>,
stderr: Option<PathBuf>,
user: Option<String>,
group: Option<String>,
cleanup_on_drop: bool,
}
impl Default for TestCtx {
fn default() -> Self {
Self {
lockfile: None,
notify_pipe: None,
pidfile: None,
lockfile_path: None,
stdout: None,
stderr: None,
user: None,
group: None,
cleanup_on_drop: true,
}
}
}
impl TestCtx {
fn build(self) -> DaemonContext {
DaemonContext::new(
self.lockfile,
self.notify_pipe,
self.pidfile,
self.lockfile_path,
self.stdout,
self.stderr,
self.user,
self.group,
self.cleanup_on_drop,
)
}
}
#[test]
fn notify_parent_writes_success_byte() {
let (rd, wr) = make_pipe();
let mut ctx = TestCtx {
notify_pipe: Some(wr),
..Default::default()
}
.build();
ctx.notify_parent().unwrap();
assert_eq!(read_pipe(rd), vec![0x00]);
}
#[test]
fn notify_parent_idempotent() {
let (_rd, wr) = make_pipe();
let mut ctx = TestCtx {
notify_pipe: Some(wr),
..Default::default()
}
.build();
ctx.notify_parent().unwrap();
ctx.notify_parent().unwrap();
}
#[test]
fn drop_writes_failure_when_not_notified() {
let (rd, wr) = make_pipe();
{
let _ctx = TestCtx {
notify_pipe: Some(wr),
..Default::default()
}
.build();
}
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 drop_no_write_after_notify() {
let (rd, wr) = make_pipe();
{
let mut ctx = TestCtx {
notify_pipe: Some(wr),
..Default::default()
}
.build();
ctx.notify_parent().unwrap();
}
assert_eq!(read_pipe(rd), vec![0x00]);
}
#[test]
fn debug_format() {
let ctx = TestCtx::default().build();
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 = TestCtx::default().build();
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 = TestCtx {
lockfile: Some(flock),
..Default::default()
}
.build();
assert!(ctx.lockfile_fd().is_some());
drop(ctx);
}
#[test]
fn lockfile_fd_returns_none_without_lockfile() {
let ctx = TestCtx::default().build();
assert!(ctx.lockfile_fd().is_none());
}
#[test]
fn drop_privileges_noop_without_user_or_group() {
let mut ctx = TestCtx::default().build();
assert!(ctx.drop_privileges().is_ok());
}
#[test]
fn chown_paths_noop_without_user_or_group() {
let mut ctx = TestCtx::default().build();
assert!(ctx.chown_paths().is_ok());
}
#[test]
fn drop_privileges_user_not_found() {
if std::env::var("CI").is_ok() {
return; }
let mut ctx = TestCtx {
user: Some("nonexistent_daemonize_test_user_xyz".into()),
..Default::default()
}
.build();
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 ctx = TestCtx {
group: Some("nonexistent_daemonize_test_group_xyz".into()),
..Default::default()
}
.build();
let result = ctx.drop_privileges();
assert!(matches!(
result,
Err(crate::DaemonizeError::GroupNotFound(_))
));
}
#[test]
fn resolve_user_numeric() {
let user = resolve_user("0").unwrap();
assert_eq!(user.uid.as_raw(), 0);
assert_eq!(user.name, "root");
}
#[test]
fn resolve_user_name() {
let user = resolve_user("root").unwrap();
assert_eq!(user.uid.as_raw(), 0);
}
#[test]
fn resolve_group_gid_numeric() {
let gid = resolve_group_gid("0").unwrap();
assert_eq!(gid.as_raw(), 0);
}
#[test]
fn context_stores_config_fields() {
let ctx = TestCtx {
pidfile: Some("/var/run/test.pid".into()),
lockfile_path: Some("/var/run/test.lock".into()),
stdout: Some("/var/log/test.out".into()),
stderr: Some("/var/log/test.err".into()),
user: Some("nobody".into()),
group: Some("nogroup".into()),
..Default::default()
}
.build();
let debug = format!("{:?}", ctx);
assert!(debug.contains("test.pid"));
assert!(debug.contains("nobody"));
assert!(debug.contains("nogroup"));
}
#[test]
fn resolve_user_nonexistent_name() {
if std::env::var("CI").is_ok() {
return;
}
let result = resolve_user("nonexistent_daemonize_test_user_xyz");
assert!(result.is_err());
}
#[test]
fn resolve_group_gid_by_name() {
let result = resolve_group_gid("root").or_else(|_| resolve_group_gid("wheel"));
assert!(result.is_ok());
}
#[test]
fn resolve_group_gid_nonexistent_name() {
if std::env::var("CI").is_ok() {
return;
}
let result = resolve_group_gid("nonexistent_daemonize_test_group_xyz");
assert!(matches!(
result,
Err(crate::DaemonizeError::GroupNotFound(_))
));
}
#[test]
fn resolve_uid_gid_user_only() {
let (uid, gid) = resolve_uid_gid(Some("root"), None).unwrap();
assert_eq!(uid, 0);
assert_eq!(gid, 0); }
#[test]
fn resolve_uid_gid_neither() {
let (uid, gid) = resolve_uid_gid(None, None).unwrap();
assert_eq!(uid, u32::MAX);
assert_eq!(gid, u32::MAX);
}
#[test]
fn resolve_uid_gid_group_only_numeric() {
let (uid, gid) = resolve_uid_gid(None, Some("0")).unwrap();
assert_eq!(uid, u32::MAX); assert_eq!(gid, 0);
}
#[test]
fn chown_paths_skips_nonexistent_files() {
let mut ctx = TestCtx {
pidfile: Some("/nonexistent_daemonize_test_xyz/test.pid".into()),
user: Some("root".into()),
cleanup_on_drop: false,
..Default::default()
}
.build();
assert!(ctx.chown_paths().is_ok());
}
#[test]
fn chown_paths_idempotent() {
let mut ctx = TestCtx::default().build();
assert!(ctx.chown_paths().is_ok());
assert!(ctx.chown_paths().is_ok());
}
#[test]
fn drop_privileges_idempotent_noop() {
let mut ctx = TestCtx::default().build();
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 = TestCtx {
pidfile: Some(pidfile.clone()),
cleanup_on_drop: false,
..Default::default()
}
.build();
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 = TestCtx {
pidfile: Some(pidfile.clone()),
cleanup_on_drop: false,
..Default::default()
}
.build();
ctx.cleanup();
ctx.cleanup(); assert!(!pidfile.exists());
}
#[test]
fn cleanup_noop_without_pidfile() {
let mut ctx = TestCtx {
cleanup_on_drop: false,
..Default::default()
}
.build();
ctx.cleanup(); }
#[test]
fn cleanup_ignores_missing_pidfile() {
let mut ctx = TestCtx {
pidfile: Some("/nonexistent_xyz/test.pid".into()),
cleanup_on_drop: false,
..Default::default()
}
.build();
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 _ctx = TestCtx {
pidfile: Some(pidfile.clone()),
..Default::default()
}
.build();
}
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 = TestCtx {
pidfile: Some(pidfile.clone()),
cleanup_on_drop: false,
..Default::default()
}
.build();
}
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 = TestCtx {
pidfile: Some(pidfile.clone()),
cleanup_on_drop: false,
..Default::default()
}
.build();
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 ctx = TestCtx {
pidfile: Some(pidfile.clone()),
lockfile_path: Some(lockfile_path.clone()),
cleanup_on_drop: false,
..Default::default()
}
.build();
ctx.cleanup();
assert!(!pidfile.exists(), "pidfile should be removed");
assert!(
lockfile_path.exists(),
"standalone lockfile should be left on disk"
);
}
}