use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
os::unix::net::UnixStream,
path::{Path, PathBuf},
time::Duration,
};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
metrics::MetricSample,
runtime,
status::{ProjectRunMode, StatusSnapshot, UnitStatus},
};
const COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(120);
const CURRENT_OP_TIMEOUT: Duration = Duration::from_secs(2);
fn runtime_dir() -> Result<PathBuf, ControlError> {
let path = runtime::state_dir();
runtime::create_private_dir(&path)?;
Ok(path)
}
pub fn socket_path() -> Result<PathBuf, ControlError> {
Ok(runtime_dir()?.join("control.sock"))
}
pub fn bind_control_socket() -> Result<std::os::unix::net::UnixListener, ControlError> {
let path = socket_path()?;
if path.exists() {
fs::remove_file(&path)?;
}
let listener = std::os::unix::net::UnixListener::bind(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
&path,
fs::Permissions::from_mode(crate::constants::PRIVATE_FILE_MODE),
)?;
}
Ok(listener)
}
pub fn lock_supervisor_runtime() -> Result<fs::File, ControlError> {
let path = runtime_dir()?.join("supervisor.lock");
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
match file.try_lock_exclusive() {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
return Err(ControlError::RuntimeBusy);
}
Err(err) => return Err(ControlError::Io(err)),
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
&path,
fs::Permissions::from_mode(crate::constants::PRIVATE_FILE_MODE),
)?;
}
Ok(file)
}
pub fn supervisor_pid_path() -> Result<PathBuf, ControlError> {
Ok(runtime_dir()?.join("sysg.pid"))
}
fn config_hint_path() -> Result<PathBuf, ControlError> {
Ok(runtime_dir()?.join("config_hint"))
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ControlCommand {
Start {
#[serde(default, skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
watch: Option<String>,
},
AddProject {
config: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(default)]
mode: ProjectRunMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
watch: Option<String>,
},
StopProject {
project: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
watch: Option<String>,
},
Stop {
#[serde(default, skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
watch: Option<String>,
},
Restart {
#[serde(default, skip_serializing_if = "Option::is_none")]
config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
watch: Option<String>,
},
Shutdown,
Status {
#[serde(default)]
live: bool,
},
Inspect {
unit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
project: Option<String>,
samples: u32,
#[serde(default)]
live: bool,
},
Logs {
#[serde(default, skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
project: Option<String>,
lines: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
kind: Option<String>,
follow: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
since: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
until: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
grep: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
all: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
structured: bool,
},
ClearLogs {
#[serde(default, skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
project: Option<String>,
},
DeclaringProjects {
service: String,
},
Version,
Upgrade {
binary: String,
},
CurrentOp,
Spawn {
parent_pid: u32,
name: String,
command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ttl: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
log_level: Option<String>,
},
BootStream,
OpStream {
op: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ControlResponse {
Ok,
Message(String),
Error(String),
Diag(Box<crate::diag::Diagnostic>),
Projects(Vec<String>),
Status(StatusSnapshot),
Inspect(Box<InspectPayload>),
Spawned {
pid: u32,
},
DaemonVersion(String),
UpgradeAccepted {
version: String,
},
CurrentOp(Option<crate::opslot::OpReport>),
}
#[derive(Debug)]
pub enum CommandAck {
Response(ControlResponse),
Pending,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InspectPayload {
pub unit: Option<UnitStatus>,
#[serde(default)]
pub samples: Vec<MetricSample>,
}
#[derive(Debug, Error)]
pub enum ControlError {
#[error("control socket I/O failed: {0}")]
Io(#[from] io::Error),
#[error("failed to serialise control message: {0}")]
Serde(#[from] serde_json::Error),
#[error("HOME environment variable not set")]
MissingHome,
#[error("supervisor reported error: {0}")]
Server(String),
#[error("control socket not available")]
NotAvailable,
#[error("supervisor did not respond in time")]
Timeout,
#[error("another supervisor owns the runtime")]
RuntimeBusy,
#[error("unauthorized control socket peer (uid {0})")]
Unauthorized(u32),
}
#[cfg(target_os = "linux")]
fn peer_uid(stream: &UnixStream) -> io::Result<u32> {
use std::os::unix::io::AsRawFd;
let mut ucred = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let res = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut ucred as *mut libc::ucred as *mut libc::c_void,
&mut len,
)
};
if res != 0 {
return Err(io::Error::last_os_error());
}
Ok(ucred.uid)
}
#[cfg(target_os = "linux")]
pub fn peer_pid(stream: &UnixStream) -> io::Result<u32> {
use std::os::unix::io::AsRawFd;
let mut ucred = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let res = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut ucred as *mut libc::ucred as *mut libc::c_void,
&mut len,
)
};
if res != 0 {
return Err(io::Error::last_os_error());
}
Ok(ucred.pid as u32)
}
#[cfg(target_os = "macos")]
pub fn peer_pid(stream: &UnixStream) -> io::Result<u32> {
use std::os::unix::io::AsRawFd;
let mut pid: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let res = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
0,
libc::LOCAL_PEERPID,
&mut pid as *mut libc::c_int as *mut libc::c_void,
&mut len,
)
};
if res != 0 {
return Err(io::Error::last_os_error());
}
Ok(pid as u32)
}
#[cfg(all(unix, not(target_os = "linux")))]
fn peer_uid(stream: &UnixStream) -> io::Result<u32> {
use std::os::unix::io::AsRawFd;
let mut uid: libc::uid_t = 0;
let mut gid: libc::gid_t = 0;
let res = unsafe { libc::getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) };
if res != 0 {
return Err(io::Error::last_os_error());
}
Ok(uid)
}
#[cfg(unix)]
pub fn authenticate_peer(stream: &UnixStream) -> Result<(), ControlError> {
let peer = peer_uid(stream)?;
let owner = unsafe { libc::getuid() };
if peer == owner || peer == 0 {
Ok(())
} else {
Err(ControlError::Unauthorized(peer))
}
}
pub fn send_command(command: &ControlCommand) -> Result<ControlResponse, ControlError> {
let stream = connect_stream()?;
stream.set_read_timeout(Some(COMMAND_READ_TIMEOUT))?;
let mut stream = stream;
write_command(&mut stream, command)?;
let mut reader = BufReader::new(stream);
let mut response_line = String::new();
match reader.read_line(&mut response_line) {
Ok(_) => {}
Err(err)
if matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
return Err(ControlError::Timeout);
}
Err(err) => return Err(err.into()),
}
if response_line.trim().is_empty() {
return Err(ControlError::NotAvailable);
}
let response: ControlResponse = serde_json::from_str(response_line.trim())?;
if let ControlResponse::Error(message) = &response {
return Err(ControlError::Server(message.clone()));
}
Ok(response)
}
pub fn current_op() -> Option<crate::opslot::OpReport> {
let stream = connect_stream().ok()?;
stream.set_read_timeout(Some(CURRENT_OP_TIMEOUT)).ok()?;
let mut stream = stream;
write_command(&mut stream, &ControlCommand::CurrentOp).ok()?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).ok()?;
match serde_json::from_str(line.trim()).ok()? {
ControlResponse::CurrentOp(report) => report,
_ => None,
}
}
pub fn send_command_detached(command: &ControlCommand) -> Result<(), ControlError> {
let mut stream = connect_stream()?;
write_command(&mut stream, command)
}
pub fn send_command_with_timeout(
command: &ControlCommand,
timeout: Duration,
) -> Result<CommandAck, ControlError> {
let mut stream = connect_stream()?;
stream.set_write_timeout(Some(timeout))?;
write_command(&mut stream, command)?;
stream.set_read_timeout(Some(timeout))?;
let mut reader = BufReader::new(stream);
let mut response_line = String::new();
match reader.read_line(&mut response_line) {
Ok(0) => Err(ControlError::NotAvailable),
Ok(_) if response_line.trim().is_empty() => Err(ControlError::NotAvailable),
Ok(_) => {
let response: ControlResponse = serde_json::from_str(response_line.trim())?;
Ok(CommandAck::Response(response))
}
Err(err)
if matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
Ok(CommandAck::Pending)
}
Err(err) => Err(err.into()),
}
}
fn connect_stream() -> Result<UnixStream, ControlError> {
let path = socket_path()?;
if !path.exists() {
return Err(ControlError::NotAvailable);
}
match UnixStream::connect(&path) {
Ok(s) => Ok(s),
Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => {
Err(ControlError::NotAvailable)
}
Err(e) => Err(e.into()),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn supervisor_peer_pid() -> Result<u32, ControlError> {
let stream = connect_stream()?;
peer_pid(&stream).map_err(ControlError::Io)
}
fn write_command(
stream: &mut UnixStream,
command: &ControlCommand,
) -> Result<(), ControlError> {
let payload = serde_json::to_vec(command)?;
stream.write_all(&payload)?;
stream.write_all(b"\n")?;
stream.flush()?;
Ok(())
}
pub fn stream_command_output(
command: &ControlCommand,
writer: impl Write,
) -> Result<(), ControlError> {
stream_command_output_interruptible(command, writer, None)
}
pub fn stream_command_output_interruptible(
command: &ControlCommand,
mut writer: impl Write,
shutdown_slot: Option<&std::sync::Mutex<Option<UnixStream>>>,
) -> Result<(), ControlError> {
let path = socket_path()?;
if !path.exists() {
return Err(ControlError::NotAvailable);
}
let mut stream = match UnixStream::connect(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => {
return Err(ControlError::NotAvailable);
}
Err(e) => return Err(e.into()),
};
let payload = serde_json::to_vec(command)?;
stream.write_all(&payload)?;
stream.write_all(b"\n")?;
stream.flush()?;
if let Some(slot) = shutdown_slot
&& let Ok(clone) = stream.try_clone()
&& let Ok(mut guard) = slot.lock()
{
*guard = Some(clone);
}
let mut reader = BufReader::new(stream);
io::copy(&mut reader, &mut writer)?;
writer.flush()?;
Ok(())
}
pub fn stream_boot_frames(
on_frame: impl FnMut(crate::start::BootFrame),
) -> Result<(), ControlError> {
stream_frames(ControlCommand::BootStream, on_frame)
}
pub fn stream_op_frames(
op: &str,
on_frame: impl FnMut(crate::start::BootFrame),
) -> Result<(), ControlError> {
stream_frames(ControlCommand::OpStream { op: op.to_string() }, on_frame)
}
fn stream_frames(
command: ControlCommand,
mut on_frame: impl FnMut(crate::start::BootFrame),
) -> Result<(), ControlError> {
let path = socket_path()?;
if !path.exists() {
return Err(ControlError::NotAvailable);
}
let mut stream = match UnixStream::connect(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => {
return Err(ControlError::NotAvailable);
}
Err(e) => return Err(e.into()),
};
write_command(&mut stream, &command)?;
let reader = BufReader::new(stream);
let mut completed = false;
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let frame: crate::start::BootFrame = serde_json::from_str(line.trim())?;
let done = frame.is_done();
on_frame(frame);
if done {
completed = true;
break;
}
}
if completed {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"boot stream ended before its terminal frame",
)
.into())
}
}
pub fn read_command(stream: &mut UnixStream) -> Result<ControlCommand, ControlError> {
let cap = crate::constants::MAX_CONTROL_LINE;
let mut reader = BufReader::new(stream).take(cap + 1);
let mut buf = Vec::new();
reader.read_until(b'\n', &mut buf)?;
if buf.len() as u64 > cap {
return Err(ControlError::Io(io::Error::new(
io::ErrorKind::InvalidData,
"control command exceeds maximum length",
)));
}
let line = String::from_utf8(buf)
.map_err(|e| ControlError::Io(io::Error::new(io::ErrorKind::InvalidData, e)))?;
if line.trim().is_empty() {
return Err(ControlError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
"empty control command",
)));
}
Ok(serde_json::from_str(line.trim())?)
}
pub fn write_response(
stream: &mut UnixStream,
response: &ControlResponse,
) -> Result<(), ControlError> {
let payload = serde_json::to_vec(response)?;
stream.write_all(&payload)?;
stream.write_all(b"\n")?;
stream.flush()?;
Ok(())
}
pub fn write_supervisor_pid(pid: libc::pid_t) -> Result<(), ControlError> {
let path = supervisor_pid_path()?;
if let Some(parent) = path.parent() {
runtime::create_private_dir(parent)?;
}
runtime::write_private_file(&path, pid.to_string())?;
Ok(())
}
pub fn write_config_hint(config: &Path) -> Result<(), ControlError> {
let hint_path = config_hint_path()?;
if let Some(parent) = hint_path.parent() {
runtime::create_private_dir(parent)?;
}
let config_str = config.to_string_lossy();
runtime::write_private_file(&hint_path, config_str.as_bytes())?;
Ok(())
}
pub fn manifest_content_hash(
config: &Path,
) -> Result<String, crate::error::ProcessManagerError> {
let content = fs::read_to_string(config)?;
let content = crate::config::resolve_includes(&content, config)?;
manifest_fingerprint(&content)
}
pub fn manifest_fingerprint(
content: &str,
) -> Result<String, crate::error::ProcessManagerError> {
let configs = crate::config::parse_config_projects(content)?;
let mut fingerprints: Vec<String> = Vec::new();
for config in &configs {
let mut svc: Vec<String> = config
.services
.iter()
.map(|(name, service)| format!("{name}={}", service.compute_hash()))
.collect();
svc.sort();
fingerprints.push(format!("{}:{}", config.project.id, svc.join(",")));
}
fingerprints.sort();
Ok(fingerprints.join("\n"))
}
pub fn read_supervisor_pid() -> Result<Option<libc::pid_t>, ControlError> {
let path = supervisor_pid_path()?;
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
contents
.trim()
.parse::<libc::pid_t>()
.map(Some)
.map_err(|e| ControlError::Io(io::Error::new(io::ErrorKind::InvalidData, e)))
}
pub fn read_config_hint() -> Result<Option<PathBuf>, ControlError> {
let hint_path = config_hint_path()?;
if !hint_path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(hint_path)?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(PathBuf::from(trimmed)))
}
pub fn cleanup_runtime() -> Result<(), ControlError> {
if let Ok(path) = socket_path()
&& path.exists()
{
let _ = fs::remove_file(path);
}
if let Ok(pid_path) = supervisor_pid_path()
&& pid_path.exists()
{
let _ = fs::remove_file(pid_path);
}
if let Ok(config_path) = config_hint_path()
&& config_path.exists()
{
let _ = fs::remove_file(config_path);
}
Ok(())
}
pub fn cleanup_runtime_owned(owner_pid: libc::pid_t) -> Result<(), ControlError> {
let still_ours = match read_supervisor_pid() {
Ok(Some(pid)) => pid == owner_pid,
Ok(None) => true,
Err(_) => false,
};
if !still_ours {
return Ok(());
}
cleanup_runtime()
}
#[cfg(test)]
mod tests {
use std::os::unix::net::UnixListener;
use tempfile::tempdir;
use super::*;
#[cfg(unix)]
#[test]
fn bind_control_socket_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let _guard = crate::test_utils::env_lock();
let temp = tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
let listener = bind_control_socket().expect("bind control socket");
drop(listener);
let path = socket_path().unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, crate::constants::PRIVATE_FILE_MODE);
cleanup_runtime().unwrap();
match original_home {
Some(val) => unsafe { std::env::set_var("HOME", val) },
None => unsafe { std::env::remove_var("HOME") },
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
}
#[test]
fn control_command_serialization() {
let start = ControlCommand::Start {
service: Some("test_service".to_string()),
project: None,
watch: None,
};
let json = serde_json::to_string(&start).unwrap();
assert!(json.contains("Start"));
assert!(json.contains("test_service"));
let stop = ControlCommand::Stop {
service: None,
project: None,
watch: None,
};
let json = serde_json::to_string(&stop).unwrap();
assert!(json.contains("Stop"));
let restart = ControlCommand::Restart {
config: Some("config.yaml".to_string()),
service: Some("service".to_string()),
project: None,
watch: None,
};
let json = serde_json::to_string(&restart).unwrap();
assert!(json.contains("Restart"));
assert!(json.contains("config.yaml"));
assert!(!json.contains("project"));
let shutdown = ControlCommand::Shutdown;
let json = serde_json::to_string(&shutdown).unwrap();
assert!(json.contains("Shutdown"));
let inspect = ControlCommand::Inspect {
unit: "svc".to_string(),
project: None,
samples: 10,
live: true,
};
let json = serde_json::to_string(&inspect).unwrap();
assert!(json.contains("Inspect"));
assert!(json.contains("\"samples\":10"));
assert!(json.contains("\"live\":true"));
let status = ControlCommand::Status { live: true };
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("Status"));
assert!(json.contains("\"live\":true"));
}
#[test]
fn restart_omits_null_optional_fields() {
let restart = ControlCommand::Restart {
config: Some("sysg.config.yaml".to_string()),
service: None,
project: None,
watch: None,
};
let json = serde_json::to_string(&restart).expect("serialize restart");
assert_eq!(json, r#"{"Restart":{"config":"sysg.config.yaml"}}"#);
}
#[test]
fn restart_deserializes_missing_and_null_optional_fields() {
let missing = r#"{"Restart":{"config":"sysg.config.yaml"}}"#;
let parsed: ControlCommand =
serde_json::from_str(missing).expect("deserialize missing fields");
assert!(matches!(
parsed,
ControlCommand::Restart {
config: Some(_),
service: None,
project: None,
..
}
));
let explicit_null =
r#"{"Restart":{"config":"sysg.config.yaml","service":null,"project":null}}"#;
let parsed: ControlCommand =
serde_json::from_str(explicit_null).expect("deserialize null fields");
assert!(matches!(
parsed,
ControlCommand::Restart {
config: Some(_),
service: None,
project: None,
..
}
));
}
#[test]
fn control_response_serialization() {
let ok = ControlResponse::Ok;
let json = serde_json::to_string(&ok).unwrap();
assert!(json.contains("Ok"));
let message = ControlResponse::Message("Service started".to_string());
let json = serde_json::to_string(&message).unwrap();
assert!(json.contains("Message"));
assert!(json.contains("Service started"));
let error = ControlResponse::Error("Failed to stop".to_string());
let json = serde_json::to_string(&error).unwrap();
assert!(json.contains("Error"));
assert!(json.contains("Failed to stop"));
let inspect_payload = InspectPayload {
unit: None,
samples: Vec::new(),
};
let json =
serde_json::to_string(&ControlResponse::Inspect(Box::new(inspect_payload)))
.unwrap();
assert!(json.contains("Inspect"));
}
#[test]
fn write_and_read_supervisor_pid() {
let _guard = crate::test_utils::env_lock();
let temp = tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
let pid = 12345;
write_supervisor_pid(pid).unwrap();
let read_pid = read_supervisor_pid().unwrap();
assert_eq!(read_pid, Some(pid));
cleanup_runtime().unwrap();
let read_pid = read_supervisor_pid().unwrap();
assert_eq!(read_pid, None);
match original_home {
Some(val) => unsafe { std::env::set_var("HOME", val) },
None => unsafe { std::env::remove_var("HOME") },
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
}
#[test]
fn write_and_read_config_hint() {
let _guard = crate::test_utils::env_lock();
let temp = tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
let config = PathBuf::from("/path/to/config.yaml");
write_config_hint(&config).unwrap();
let hint = read_config_hint().unwrap();
assert_eq!(hint, Some(config));
cleanup_runtime().unwrap();
let hint = read_config_hint().unwrap();
assert_eq!(hint, None);
match original_home {
Some(val) => unsafe { std::env::set_var("HOME", val) },
None => unsafe { std::env::remove_var("HOME") },
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
}
#[test]
fn send_command_no_socket() {
let _guard = crate::test_utils::env_lock();
let temp = tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
let command = ControlCommand::Shutdown;
let result = send_command(&command);
assert!(matches!(result, Err(ControlError::NotAvailable)));
match original_home {
Some(val) => unsafe { std::env::set_var("HOME", val) },
None => unsafe { std::env::remove_var("HOME") },
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
}
#[test]
fn write_and_read_command_response() {
let temp = tempdir().unwrap();
let socket_path = temp.path().join("test.sock");
let listener = match UnixListener::bind(&socket_path) {
Ok(listener) => listener,
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
return;
}
Err(err) => panic!("failed to bind test socket: {err}"),
};
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let cmd = read_command(&mut stream).unwrap();
assert!(matches!(cmd, ControlCommand::Start { .. }));
let response = ControlResponse::Message("Started".to_string());
write_response(&mut stream, &response).unwrap();
});
std::thread::sleep(std::time::Duration::from_millis(100));
let mut stream = UnixStream::connect(&socket_path).unwrap();
let command = ControlCommand::Start {
service: Some("test".to_string()),
project: None,
watch: None,
};
let payload = serde_json::to_vec(&command).unwrap();
stream.write_all(&payload).unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let response: ControlResponse = serde_json::from_str(line.trim()).unwrap();
assert!(matches!(response, ControlResponse::Message(msg) if msg == "Started"));
}
#[test]
fn read_command_rejects_oversized_line() {
let temp = tempdir().unwrap();
let socket_path = temp.path().join("oversize.sock");
let listener = match UnixListener::bind(&socket_path) {
Ok(listener) => listener,
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => return,
Err(err) => panic!("failed to bind test socket: {err}"),
};
std::thread::spawn(move || {
if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let payload =
vec![b'a'; (crate::constants::MAX_CONTROL_LINE as usize) + 16];
let _ = stream.write_all(&payload);
let _ = stream.flush();
}
});
let (mut stream, _) = listener.accept().unwrap();
let result = read_command(&mut stream);
assert!(matches!(
result,
Err(ControlError::Io(err)) if err.kind() == io::ErrorKind::InvalidData
));
}
#[test]
fn control_error_from_io_error() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let ctrl_err: ControlError = io_err.into();
match ctrl_err {
ControlError::Io(_) => {}
_ => panic!("Expected Io error variant"),
}
}
#[test]
fn control_error_from_serde_error() {
let json = "{invalid json}";
let serde_err = serde_json::from_str::<ControlCommand>(json).unwrap_err();
let ctrl_err: ControlError = serde_err.into();
match ctrl_err {
ControlError::Serde(_) => {}
_ => panic!("Expected Serde error variant"),
}
}
#[test]
fn runtime_dir_creation() {
let _guard = crate::test_utils::env_lock();
let temp = tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
let dir = runtime_dir().unwrap();
assert!(dir.ends_with(".local/share/systemg"));
assert!(dir.exists());
match original_home {
Some(val) => unsafe { std::env::set_var("HOME", val) },
None => unsafe { std::env::remove_var("HOME") },
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
}
#[test]
fn socket_path_generation() {
let _guard = crate::test_utils::env_lock();
let temp = tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
let path = socket_path().unwrap();
assert!(path.ends_with("control.sock"));
match original_home {
Some(val) => unsafe { std::env::set_var("HOME", val) },
None => unsafe { std::env::remove_var("HOME") },
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
}
#[test]
fn empty_config_hint_handled() {
let _guard = crate::test_utils::env_lock();
let temp = tempdir().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
let hint_path = config_hint_path().unwrap();
fs::create_dir_all(hint_path.parent().unwrap()).unwrap();
fs::write(&hint_path, "").unwrap();
let hint = read_config_hint().unwrap();
assert_eq!(hint, None);
match original_home {
Some(val) => unsafe { std::env::set_var("HOME", val) },
None => unsafe { std::env::remove_var("HOME") },
}
crate::runtime::init(crate::runtime::RuntimeMode::User);
crate::runtime::set_drop_privileges(false);
}
}