Skip to main content

pitchfork_cli/
daemon.rs

1use crate::daemon_id::DaemonId;
2use crate::daemon_status::DaemonStatus;
3use crate::pitchfork_toml::{CpuLimit, CronRetrigger, MemoryLimit};
4use indexmap::IndexMap;
5use std::fmt::Display;
6use std::path::PathBuf;
7
8/// Validates a daemon ID to ensure it's safe for use in file paths and IPC.
9///
10/// A valid daemon ID:
11/// - Is not empty
12/// - Does not contain backslashes (`\`)
13/// - Does not contain parent directory references (`..`)
14/// - Does not contain spaces
15/// - Does not contain `--` (reserved for path encoding of `/`)
16/// - Is not `.` (current directory)
17/// - Contains only printable ASCII characters
18/// - If qualified (contains `/`), has exactly one `/` separating namespace and short ID
19///
20/// Format: `[namespace/]short_id`
21/// - Qualified: `project/api`, `global/web`
22/// - Short: `api`, `web`
23///
24/// This validation prevents path traversal attacks when daemon IDs are used
25/// to construct log file paths or other filesystem operations.
26pub fn is_valid_daemon_id(id: &str) -> bool {
27    if id.contains('/') {
28        DaemonId::parse(id).is_ok()
29    } else {
30        DaemonId::try_new("global", id).is_ok()
31    }
32}
33
34/// Converts a daemon ID to a filesystem-safe path component.
35///
36/// Replaces `/` with `--` to avoid issues with filesystem path separators.
37///
38/// Examples:
39/// - `"api"` → `"api"`
40/// - `"global/api"` → `"global--api"`
41/// - `"project-a/api"` → `"project-a--api"`
42pub fn daemon_id_to_path(id: &str) -> String {
43    id.replace('/', "--")
44}
45
46/// Returns the main log file path for a daemon.
47///
48/// The path is computed as: `$PITCHFORK_LOGS_DIR/{safe_id}/{safe_id}.log`
49/// where `safe_id` has `/` replaced with `--` for filesystem safety.
50///
51/// Prefer using `DaemonId::log_path()` when you have a structured ID.
52pub fn daemon_log_path(id: &str) -> std::path::PathBuf {
53    let safe_id = daemon_id_to_path(id);
54    crate::env::PITCHFORK_LOGS_DIR
55        .join(&safe_id)
56        .join(format!("{safe_id}.log"))
57}
58
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct Daemon {
61    pub id: DaemonId,
62    pub title: Option<String>,
63    pub pid: Option<u32>,
64    pub shell_pid: Option<u32>,
65    pub status: DaemonStatus,
66    pub dir: Option<PathBuf>,
67    #[serde(skip_serializing_if = "Option::is_none", default)]
68    pub cmd: Option<Vec<String>>,
69    pub autostop: bool,
70    #[serde(skip_serializing_if = "Option::is_none", default)]
71    pub cron_schedule: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub cron_retrigger: Option<CronRetrigger>,
74    #[serde(skip_serializing_if = "Option::is_none", default)]
75    pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub last_exit_success: Option<bool>,
78    #[serde(default)]
79    pub retry: u32,
80    #[serde(default)]
81    pub retry_count: u32,
82    #[serde(skip_serializing_if = "Option::is_none", default)]
83    pub ready_delay: Option<u64>,
84    #[serde(skip_serializing_if = "Option::is_none", default)]
85    pub ready_output: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none", default)]
87    pub ready_http: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none", default)]
89    pub ready_port: Option<u16>,
90    #[serde(skip_serializing_if = "Option::is_none", default)]
91    pub ready_cmd: Option<String>,
92    /// Expected ports from configuration (before auto-bump resolution)
93    #[serde(skip_serializing_if = "Vec::is_empty", default)]
94    pub expected_port: Vec<u16>,
95    /// Resolved ports actually used after auto-bump (may differ from expected)
96    #[serde(skip_serializing_if = "Vec::is_empty", default)]
97    pub resolved_port: Vec<u16>,
98    #[serde(default)]
99    pub auto_bump_port: bool,
100    #[serde(default)]
101    pub port_bump_attempts: u32,
102    #[serde(skip_serializing_if = "Vec::is_empty", default)]
103    pub depends: Vec<DaemonId>,
104    #[serde(skip_serializing_if = "Option::is_none", default)]
105    pub env: Option<IndexMap<String, String>>,
106    #[serde(skip_serializing_if = "Vec::is_empty", default)]
107    pub watch: Vec<String>,
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub watch_base_dir: Option<PathBuf>,
110    #[serde(default)]
111    pub mise: bool,
112    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
113    #[serde(skip_serializing_if = "Option::is_none", default)]
114    pub memory_limit: Option<MemoryLimit>,
115    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub cpu_limit: Option<CpuLimit>,
118}
119
120#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
121pub struct RunOptions {
122    pub id: DaemonId,
123    pub cmd: Vec<String>,
124    pub force: bool,
125    pub shell_pid: Option<u32>,
126    pub dir: PathBuf,
127    pub autostop: bool,
128    pub cron_schedule: Option<String>,
129    pub cron_retrigger: Option<CronRetrigger>,
130    pub retry: u32,
131    pub retry_count: u32,
132    pub ready_delay: Option<u64>,
133    pub ready_output: Option<String>,
134    pub ready_http: Option<String>,
135    pub ready_port: Option<u16>,
136    pub ready_cmd: Option<String>,
137    pub expected_port: Vec<u16>,
138    pub auto_bump_port: bool,
139    pub port_bump_attempts: u32,
140    pub wait_ready: bool,
141    #[serde(skip_serializing_if = "Vec::is_empty", default)]
142    pub depends: Vec<DaemonId>,
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub env: Option<IndexMap<String, String>>,
145    #[serde(skip_serializing_if = "Vec::is_empty", default)]
146    pub watch: Vec<String>,
147    #[serde(skip_serializing_if = "Option::is_none", default)]
148    pub watch_base_dir: Option<PathBuf>,
149    #[serde(default)]
150    pub mise: bool,
151    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
152    #[serde(skip_serializing_if = "Option::is_none", default)]
153    pub memory_limit: Option<MemoryLimit>,
154    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
155    #[serde(skip_serializing_if = "Option::is_none", default)]
156    pub cpu_limit: Option<CpuLimit>,
157}
158
159impl Default for Daemon {
160    fn default() -> Self {
161        Self {
162            id: DaemonId::default(),
163            title: None,
164            pid: None,
165            shell_pid: None,
166            status: DaemonStatus::default(),
167            dir: None,
168            cmd: None,
169            autostop: false,
170            cron_schedule: None,
171            cron_retrigger: None,
172            last_cron_triggered: None,
173            last_exit_success: None,
174            retry: 0,
175            retry_count: 0,
176            ready_delay: None,
177            ready_output: None,
178            ready_http: None,
179            ready_port: None,
180            ready_cmd: None,
181            expected_port: Vec::new(),
182            resolved_port: Vec::new(),
183            auto_bump_port: false,
184            port_bump_attempts: 10,
185            depends: Vec::new(),
186            env: None,
187            watch: Vec::new(),
188            watch_base_dir: None,
189            mise: false,
190            memory_limit: None,
191            cpu_limit: None,
192        }
193    }
194}
195
196impl Daemon {
197    /// Build RunOptions from persisted daemon state.
198    ///
199    /// Carries over all configuration fields from the daemon state.
200    /// Callers can override specific fields on the returned value.
201    pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
202        RunOptions {
203            id: self.id.clone(),
204            cmd,
205            force: false,
206            shell_pid: self.shell_pid,
207            dir: self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone()),
208            autostop: self.autostop,
209            cron_schedule: self.cron_schedule.clone(),
210            cron_retrigger: self.cron_retrigger,
211            retry: self.retry,
212            retry_count: self.retry_count,
213            ready_delay: self.ready_delay,
214            ready_output: self.ready_output.clone(),
215            ready_http: self.ready_http.clone(),
216            ready_port: self.ready_port,
217            ready_cmd: self.ready_cmd.clone(),
218            expected_port: self.expected_port.clone(),
219            auto_bump_port: self.auto_bump_port,
220            port_bump_attempts: self.port_bump_attempts,
221            wait_ready: false,
222            depends: self.depends.clone(),
223            env: self.env.clone(),
224            watch: self.watch.clone(),
225            watch_base_dir: self.watch_base_dir.clone(),
226            mise: self.mise,
227            memory_limit: self.memory_limit,
228            cpu_limit: self.cpu_limit,
229        }
230    }
231}
232
233impl Default for RunOptions {
234    fn default() -> Self {
235        Self {
236            id: DaemonId::default(),
237            cmd: Vec::new(),
238            force: false,
239            shell_pid: None,
240            dir: crate::env::CWD.clone(),
241            autostop: false,
242            cron_schedule: None,
243            cron_retrigger: None,
244            retry: 0,
245            retry_count: 0,
246            ready_delay: None,
247            ready_output: None,
248            ready_http: None,
249            ready_port: None,
250            ready_cmd: None,
251            expected_port: Vec::new(),
252            auto_bump_port: false,
253            port_bump_attempts: 10,
254            wait_ready: false,
255            depends: Vec::new(),
256            env: None,
257            watch: Vec::new(),
258            watch_base_dir: None,
259            mise: false,
260            memory_limit: None,
261            cpu_limit: None,
262        }
263    }
264}
265
266impl Display for Daemon {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        write!(f, "{}", self.id.qualified())
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_valid_daemon_ids() {
278        // Short IDs
279        assert!(is_valid_daemon_id("myapp"));
280        assert!(is_valid_daemon_id("my-app"));
281        assert!(is_valid_daemon_id("my_app"));
282        assert!(is_valid_daemon_id("my.app"));
283        assert!(is_valid_daemon_id("MyApp123"));
284
285        // Qualified IDs (namespace/short_id)
286        assert!(is_valid_daemon_id("project/api"));
287        assert!(is_valid_daemon_id("global/web"));
288        assert!(is_valid_daemon_id("my-project/my-app"));
289    }
290
291    #[test]
292    fn test_invalid_daemon_ids() {
293        // Empty
294        assert!(!is_valid_daemon_id(""));
295
296        // Multiple slashes (invalid qualified format)
297        assert!(!is_valid_daemon_id("a/b/c"));
298        assert!(!is_valid_daemon_id("../etc/passwd"));
299
300        // Invalid qualified format (empty parts)
301        assert!(!is_valid_daemon_id("/api"));
302        assert!(!is_valid_daemon_id("project/"));
303
304        // Backslashes
305        assert!(!is_valid_daemon_id("foo\\bar"));
306
307        // Parent directory reference
308        assert!(!is_valid_daemon_id(".."));
309        assert!(!is_valid_daemon_id("foo..bar"));
310
311        // Double dash (reserved for path encoding)
312        assert!(!is_valid_daemon_id("my--app"));
313        assert!(!is_valid_daemon_id("project--api"));
314        assert!(!is_valid_daemon_id("--app"));
315        assert!(!is_valid_daemon_id("app--"));
316
317        // Spaces
318        assert!(!is_valid_daemon_id("my app"));
319        assert!(!is_valid_daemon_id(" myapp"));
320        assert!(!is_valid_daemon_id("myapp "));
321
322        // Current directory
323        assert!(!is_valid_daemon_id("."));
324
325        // Control characters
326        assert!(!is_valid_daemon_id("my\x00app"));
327        assert!(!is_valid_daemon_id("my\napp"));
328        assert!(!is_valid_daemon_id("my\tapp"));
329
330        // Non-ASCII
331        assert!(!is_valid_daemon_id("myäpp"));
332        assert!(!is_valid_daemon_id("приложение"));
333
334        // Unsupported punctuation under DaemonId rules
335        assert!(!is_valid_daemon_id("app@host"));
336        assert!(!is_valid_daemon_id("app:8080"));
337    }
338}