refactor paths: remove HOME dependency, use env/XDG/tmp fallback

- socket/log/enabled paths resolved via state.rs with priority:
  RSS_SOCKET/RSS_STATE_DIR/RSS_CONFIG_DIR env → XDG_RUNTIME_DIR
  → ~/.local/share/rss → /tmp/rss-niko
- config path: RSS_CONFIG → RSS_CONFIG_DIR/config.toml → ~/.config/rss
  → /etc/rss/config.toml
- removed State struct; all functions are module-level
- daemon chmods socket parent dir for group access
- clean up stale PID file on config load failure
This commit is contained in:
Niko Marmeladkov 2026-07-03 13:04:58 +03:00
parent b069c527bd
commit 7383445ea5
Signed by untrusted user who does not match committer: Niko
GPG key ID: E3B955F9442D44E3
5 changed files with 116 additions and 105 deletions

View file

@ -8,6 +8,7 @@ ExecStart=%h/.cargo/bin/rss daemon
Restart=on-failure
RestartSec=5
Environment=RSS_CONFIG=%h/.config/rss/config.toml
Environment=RSS_STATE_DIR=%h/.local/share/rss
[Install]
WantedBy=default.target

View file

@ -1,12 +1,14 @@
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, SystemTime};
use std::sync::atomic::{AtomicBool, Ordering};
use crate::config::{Config, RestartPolicy, ServiceConfig};
use crate::state::State;
use crate::state;
static STOP: AtomicBool = AtomicBool::new(false);
@ -35,16 +37,24 @@ pub fn run(config_path: String) {
}
// Write daemon PID
let pid_path = crate::ipc::daemon_pid_path();
State::write_pid(&pid_path).ok();
let pid_path = state::daemon_pid_path();
state::write_pid(&pid_path).ok();
// Ensure socket path is clean
let sock_path = crate::ipc::socket_path();
let sock_path = state::socket_path();
if sock_path.exists() {
let _ = std::fs::remove_file(&sock_path);
let _ = fs::remove_file(&sock_path);
}
if let Some(parent) = sock_path.parent() {
let _ = std::fs::create_dir_all(parent);
fs::create_dir_all(parent).ok();
// Make directory group-accessible (rwxrwx---)
if let Ok(dir) = fs::metadata(parent) {
let perm = dir.permissions();
let mode = perm.mode();
// set group rwx if owner has it
let group = (mode & 0o700) >> 3;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(mode | group));
}
}
// Load config
@ -52,6 +62,7 @@ pub fn run(config_path: String) {
Ok(c) => c,
Err(e) => {
eprintln!("error: config: {}", e);
let _ = fs::remove_file(&pid_path);
return;
}
};
@ -59,12 +70,11 @@ pub fn run(config_path: String) {
let listener = UnixListener::bind(&sock_path).expect("bind socket");
listener.set_nonblocking(true).ok();
let state = State::new();
let mut procs: HashMap<String, Proc> = HashMap::new();
let mut last_mtime: Option<SystemTime> = config_mtime(&config_path);
// Start enabled services
start_enabled(&config, &state, &mut procs);
start_enabled(&config, &mut procs);
// Main loop
loop {
@ -81,7 +91,7 @@ pub fn run(config_path: String) {
last_mtime = Some(mtime);
let new_config = Config::load(&config_path);
if let Ok(new_config) = new_config {
reconcile(&new_config, &state, &mut procs);
reconcile(&new_config, &mut procs);
config = new_config;
}
}
@ -92,7 +102,7 @@ pub fn run(config_path: String) {
// Accept socket connections
while let Ok((stream, _)) = listener.accept() {
handle_client(stream, &config, &state, &mut procs, &config_path);
handle_client(&stream, &config, &mut procs, &config_path);
}
std::thread::sleep(Duration::from_millis(200));
@ -103,8 +113,8 @@ fn config_mtime(path: &str) -> Option<SystemTime> {
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
fn start_enabled(config: &Config, state: &State, procs: &mut HashMap<String, Proc>) {
if let Ok(enabled) = state.list_enabled() {
fn start_enabled(config: &Config, procs: &mut HashMap<String, Proc>) {
if let Ok(enabled) = crate::state::list_enabled() {
for name in &enabled {
if let Some(cfg) = config.service.get(name) {
if !procs.contains_key(name) {
@ -117,7 +127,7 @@ fn start_enabled(config: &Config, state: &State, procs: &mut HashMap<String, Pro
}
}
fn reconcile(new_config: &Config, state: &State, procs: &mut HashMap<String, Proc>) {
fn reconcile(new_config: &Config, procs: &mut HashMap<String, Proc>) {
let old_names: Vec<String> = procs.keys().cloned().collect();
for name in &old_names {
@ -127,7 +137,7 @@ fn reconcile(new_config: &Config, state: &State, procs: &mut HashMap<String, Pro
}
for (name, cfg) in &new_config.service {
if !procs.contains_key(name) && state.is_enabled(name) {
if !procs.contains_key(name) && crate::state::is_enabled(name) {
if let Some(p) = spawn(name, cfg) {
procs.insert(name.clone(), p);
}
@ -136,14 +146,11 @@ fn reconcile(new_config: &Config, state: &State, procs: &mut HashMap<String, Pro
}
fn spawn(name: &str, cfg: &ServiceConfig) -> Option<Proc> {
let log_path = format!(
"{}/.local/share/rss/logs/{}.log",
std::env::var("HOME").unwrap_or_default(),
name
);
if let Some(parent) = std::path::Path::new(&log_path).parent() {
let log_path = crate::state::log_path(name);
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let log_path = log_path.to_string_lossy().to_string();
let cmd = cfg.resolve_command(name, &log_path);
let mut child = match Command::new("sh")
@ -326,14 +333,13 @@ fn check_children(procs: &mut HashMap<String, Proc>) {
}
fn handle_client(
mut stream: UnixStream,
mut stream: &UnixStream,
config: &Config,
state: &State,
procs: &mut HashMap<String, Proc>,
_config_path: &str,
) {
let mut line = String::new();
let mut reader = BufReader::new(&stream);
let mut reader = BufReader::new(stream);
if reader.read_line(&mut line).is_err() {
return;
}
@ -387,7 +393,7 @@ fn handle_client(
"list" | "list " => {
let mut out = Vec::new();
for (name, _cfg) in &config.service {
let enabled = if state.is_enabled(name) { "yes" } else { "no" };
let enabled = if crate::state::is_enabled(name) { "yes" } else { "no" };
let info = if let Some(p) = procs.get(name) {
format!("running {} pid={} enabled={}", name, p.pid, enabled)
} else {
@ -399,7 +405,7 @@ fn handle_client(
}
l if l.starts_with("status ") => {
let name = &l[7..];
let enabled = if state.is_enabled(name) { "yes" } else { "no" };
let enabled = if crate::state::is_enabled(name) { "yes" } else { "no" };
if let Some(p) = procs.get(name) {
format!("running {} pid={} enabled={}", name, p.pid, enabled)
} else if config.service.contains_key(name) {
@ -411,7 +417,7 @@ fn handle_client(
"status" => {
let mut out = Vec::new();
for (name, _) in &config.service {
let enabled = if state.is_enabled(name) { "yes" } else { "no" };
let enabled = if crate::state::is_enabled(name) { "yes" } else { "no" };
let info = if let Some(p) = procs.get(name) {
format!("running {} pid={} enabled={}", name, p.pid, enabled)
} else {

View file

@ -1,19 +1,8 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
pub fn socket_path() -> PathBuf {
let home = std::env::var("HOME").expect("HOME not set");
PathBuf::from(format!("{}/.local/share/rss/rss.sock", home))
}
pub fn daemon_pid_path() -> PathBuf {
let home = std::env::var("HOME").expect("HOME not set");
PathBuf::from(format!("{}/.local/share/rss/daemon.pid", home))
}
pub fn send_cmd(cmd: &str) -> Result<Vec<String>, String> {
let path = socket_path();
let path = crate::state::socket_path();
if path.exists() {
if UnixStream::connect(&path).is_err() {

View file

@ -8,7 +8,7 @@ mod supervisor;
use clap::Parser;
use cli::{Cli, Commands};
use state::State;
fn main() {
let cli = Cli::parse();
@ -40,8 +40,7 @@ fn resolve_config_path(custom: Option<&str>) -> String {
if let Ok(path) = std::env::var("RSS_CONFIG") {
return path;
}
let home = std::env::var("HOME").expect("HOME not set");
format!("{}/.config/rss/config.toml", home)
state::config_dir().join("config.toml").to_string_lossy().to_string()
}
fn cmd_start(name: &str) {
@ -128,8 +127,7 @@ fn cmd_logs(name: &str, mut lines: Option<usize>, follow: bool) {
lines = Some(10);
}
let state = State::new();
let log_path = state.log_path(name);
let log_path = state::log_path(name);
if !log_path.exists() {
eprintln!("error: no logs for '{}'", name);
@ -143,8 +141,6 @@ fn cmd_logs(name: &str, mut lines: Option<usize>, follow: bool) {
}
fn cmd_enable(name: &str, now: bool) {
let state = State::new();
let config_path = resolve_config_path(None);
let config = match config::Config::load(&config_path) {
Ok(c) => c,
@ -159,7 +155,7 @@ fn cmd_enable(name: &str, now: bool) {
return;
}
if let Err(e) = state.enable(name) {
if let Err(e) = state::enable(name) {
eprintln!("error: enabling '{}': {}", name, e);
return;
}
@ -172,7 +168,6 @@ fn cmd_enable(name: &str, now: bool) {
}
fn cmd_disable(name: &str) {
let state = State::new();
state.disable(name);
state::disable(name);
println!("disabled {}", name);
}

View file

@ -1,64 +1,84 @@
use std::path::PathBuf;
pub struct State {
pub state_dir: PathBuf,
config_dir: PathBuf,
pub fn runtime_dir() -> PathBuf {
if let Ok(dir) = std::env::var("RSS_STATE_DIR") {
return PathBuf::from(dir);
}
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
return PathBuf::from(dir).join("rss");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".local/share/rss");
}
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
PathBuf::from(format!("/tmp/rss-{}", user))
}
impl State {
pub fn new() -> Self {
let home = std::env::var("HOME").expect("HOME not set");
State {
state_dir: PathBuf::from(format!("{}/.local/share/rss", home)),
config_dir: PathBuf::from(format!("{}/.config/rss", home)),
}
pub fn config_dir() -> PathBuf {
if let Ok(dir) = std::env::var("RSS_CONFIG_DIR") {
return PathBuf::from(dir);
}
pub fn log_path(&self, name: &str) -> PathBuf {
self.state_dir.join("logs").join(format!("{}.log", name))
}
pub fn enabled_path(&self, name: &str) -> PathBuf {
self.config_dir.join("enabled").join(name)
}
pub fn write_pid(path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, format!("{}", std::process::id()))
}
pub fn is_enabled(&self, name: &str) -> bool {
self.enabled_path(name).exists()
}
pub fn enable(&self, name: &str) -> std::io::Result<()> {
let path = self.enabled_path(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, "")?;
Ok(())
}
pub fn disable(&self, name: &str) {
let _ = std::fs::remove_file(self.enabled_path(name));
}
pub fn list_enabled(&self) -> std::io::Result<Vec<String>> {
let dir = self.config_dir.join("enabled");
if !dir.exists() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
names.sort();
Ok(names)
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".config/rss");
}
PathBuf::from("/etc/rss")
}
pub fn socket_path() -> PathBuf {
if let Ok(p) = std::env::var("RSS_SOCKET") {
return PathBuf::from(p);
}
runtime_dir().join("rss.sock")
}
pub fn daemon_pid_path() -> PathBuf {
runtime_dir().join("daemon.pid")
}
pub fn log_path(name: &str) -> PathBuf {
runtime_dir().join("logs").join(format!("{}.log", name))
}
pub fn enabled_path(name: &str) -> PathBuf {
config_dir().join("enabled").join(name)
}
pub fn write_pid(path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, format!("{}", std::process::id()))
}
pub fn is_enabled(name: &str) -> bool {
enabled_path(name).exists()
}
pub fn enable(name: &str) -> std::io::Result<()> {
let path = enabled_path(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, "")?;
Ok(())
}
pub fn disable(name: &str) {
let _ = std::fs::remove_file(enabled_path(name));
}
pub fn list_enabled() -> std::io::Result<Vec<String>> {
let dir = config_dir().join("enabled");
if !dir.exists() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
names.sort();
Ok(names)
}