Skip to main content

pitchfork_cli/
daemon.rs

1use crate::daemon_id::DaemonId;
2use crate::daemon_status::DaemonStatus;
3use crate::pitchfork_toml::{
4    CpuLimit, CronRetrigger, Dir, MemoryLimit, PortConfig, ReadyHttp, Retry, StopConfig, WatchMode,
5};
6use indexmap::IndexMap;
7use std::fmt::Display;
8use std::path::PathBuf;
9
10/// Validates a daemon ID to ensure it's safe for use in file paths and IPC.
11///
12/// A valid daemon ID:
13/// - Is not empty
14/// - Does not contain backslashes (`\`)
15/// - Does not contain parent directory references (`..`)
16/// - Does not contain spaces
17/// - Does not contain `--` (reserved for path encoding of `/`)
18/// - Is not `.` (current directory)
19/// - Contains only printable ASCII characters
20/// - If qualified (contains `/`), has exactly one `/` separating namespace and short ID
21///
22/// Format: `[namespace/]short_id`
23/// - Qualified: `project/api`, `global/web`
24/// - Short: `api`, `web`
25///
26/// This validation prevents path traversal attacks when daemon IDs are used
27/// to construct log file paths or other filesystem operations.
28pub fn is_valid_daemon_id(id: &str) -> bool {
29    if id.contains('/') {
30        DaemonId::parse(id).is_ok()
31    } else {
32        DaemonId::try_new("global", id).is_ok()
33    }
34}
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
37pub struct Daemon {
38    pub id: DaemonId,
39    pub title: Option<String>,
40    pub pid: Option<u32>,
41    pub shell_pid: Option<u32>,
42    pub status: DaemonStatus,
43    pub dir: Option<PathBuf>,
44    #[serde(skip_serializing_if = "Option::is_none", default)]
45    pub cmd: Option<Vec<String>>,
46    /// Original shell command string, persisted for retry/watch restarts.
47    #[serde(skip_serializing_if = "Option::is_none", default)]
48    pub run: Option<String>,
49    pub autostop: bool,
50    #[serde(skip_serializing_if = "Option::is_none", default)]
51    pub cron_schedule: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none", default)]
53    pub cron_retrigger: Option<CronRetrigger>,
54    #[serde(skip_serializing_if = "Option::is_none", default)]
55    pub cron_immediate: Option<bool>,
56    #[serde(skip_serializing_if = "Option::is_none", default)]
57    pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
58    #[serde(skip_serializing_if = "Option::is_none", default)]
59    pub last_exit_success: Option<bool>,
60    #[serde(default)]
61    pub retry: Retry,
62    #[serde(default)]
63    pub retry_count: u32,
64    #[serde(skip_serializing_if = "Option::is_none", default)]
65    pub ready_delay: Option<u64>,
66    #[serde(skip_serializing_if = "Option::is_none", default)]
67    pub ready_output: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    pub ready_http: Option<ReadyHttp>,
70    #[serde(skip_serializing_if = "Option::is_none", default)]
71    pub ready_port: Option<u16>,
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub ready_cmd: Option<String>,
74    /// Port configuration (expected ports and auto-bump settings)
75    #[serde(skip_serializing_if = "Option::is_none", default)]
76    pub port: Option<PortConfig>,
77    /// Resolved ports actually used after auto-bump (may differ from expected)
78    #[serde(skip_serializing_if = "Vec::is_empty", default)]
79    pub resolved_port: Vec<u16>,
80    /// The first port the process is actually listening on (detected at runtime via listeners crate).
81    /// This is the source of truth for the reverse proxy. Cleared when the daemon stops.
82    #[serde(skip_serializing_if = "Option::is_none", default)]
83    pub active_port: Option<u16>,
84    /// Optional stable slug alias for this daemon (used in proxy URLs and CLI commands).
85    #[serde(skip_serializing_if = "Option::is_none", default)]
86    pub slug: Option<String>,
87    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
88    #[serde(skip_serializing_if = "Option::is_none", default)]
89    pub proxy: Option<bool>,
90    #[serde(skip_serializing_if = "Vec::is_empty", default)]
91    pub depends: Vec<DaemonId>,
92    #[serde(skip_serializing_if = "Option::is_none", default)]
93    pub env: Option<IndexMap<String, String>>,
94    #[serde(skip_serializing_if = "Vec::is_empty", default)]
95    pub watch: Vec<String>,
96    #[serde(default)]
97    pub watch_mode: WatchMode,
98    #[serde(skip_serializing_if = "Option::is_none", default)]
99    pub watch_base_dir: Option<PathBuf>,
100    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
101    ///
102    /// # Schema compatibility note
103    /// This field changed from `bool` to `Option<bool>` with `skip_serializing_if = "Option::is_none"`.
104    /// - **Upgrade (old → new):** safe — old files contain `mise = true/false`, which deserialize
105    ///   correctly as `Some(true)` / `Some(false)`.
106    /// - **Downgrade (new → old):** if `mise` is `None` (inherit global), the key is omitted from
107    ///   the state file. An old binary reads the missing key as `false`, ignoring `general.mise = true`.
108    ///   Any daemon that relied on the global setting would silently stop using mise after a downgrade.
109    #[serde(skip_serializing_if = "Option::is_none", default)]
110    pub mise: Option<bool>,
111    /// Unix user to run this daemon as.
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub user: Option<String>,
114    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
115    #[serde(skip_serializing_if = "Option::is_none", default)]
116    pub memory_limit: Option<MemoryLimit>,
117    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
118    #[serde(skip_serializing_if = "Option::is_none", default)]
119    pub cpu_limit: Option<CpuLimit>,
120    /// Unix signal to send for graceful shutdown (default: SIGTERM)
121    #[serde(skip_serializing_if = "Option::is_none", default)]
122    pub stop_signal: Option<StopConfig>,
123    /// Allocate a pseudo-terminal for the daemon process.
124    #[serde(skip_serializing_if = "Option::is_none", default)]
125    pub pty: Option<bool>,
126}
127
128#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
129pub struct RunOptions {
130    pub id: DaemonId,
131    pub cmd: Vec<String>,
132    /// Original shell command string (from config `run`), passed verbatim to the shell.
133    /// Falls back to joining `cmd` when None (e.g. ad-hoc `pitchfork run -- cmd args`).
134    #[serde(skip_serializing_if = "Option::is_none", default)]
135    pub run: Option<String>,
136    pub force: bool,
137    pub shell_pid: Option<u32>,
138    pub dir: Dir,
139    pub autostop: bool,
140    pub cron_schedule: Option<String>,
141    pub cron_retrigger: Option<CronRetrigger>,
142    pub cron_immediate: Option<bool>,
143    pub retry: Retry,
144    pub retry_count: u32,
145    pub ready_delay: Option<u64>,
146    pub ready_output: Option<String>,
147    pub ready_http: Option<ReadyHttp>,
148    pub ready_port: Option<u16>,
149    pub ready_cmd: Option<String>,
150    pub port: Option<PortConfig>,
151    pub wait_ready: bool,
152    #[serde(skip_serializing_if = "Vec::is_empty", default)]
153    pub depends: Vec<DaemonId>,
154    #[serde(skip_serializing_if = "Option::is_none", default)]
155    pub env: Option<IndexMap<String, String>>,
156    #[serde(skip_serializing_if = "Vec::is_empty", default)]
157    pub watch: Vec<String>,
158    #[serde(default)]
159    pub watch_mode: WatchMode,
160    #[serde(skip_serializing_if = "Option::is_none", default)]
161    pub watch_base_dir: Option<PathBuf>,
162    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
163    ///
164    /// # Schema compatibility note
165    /// See `Daemon::mise` for downgrade implications when this field is `None`.
166    #[serde(skip_serializing_if = "Option::is_none", default)]
167    pub mise: Option<bool>,
168    /// Optional stable slug alias for this daemon.
169    #[serde(skip_serializing_if = "Option::is_none", default)]
170    pub slug: Option<String>,
171    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
172    #[serde(skip_serializing_if = "Option::is_none", default)]
173    pub proxy: Option<bool>,
174    /// Unix user to run this daemon as.
175    #[serde(skip_serializing_if = "Option::is_none", default)]
176    pub user: Option<String>,
177    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
178    #[serde(skip_serializing_if = "Option::is_none", default)]
179    pub memory_limit: Option<MemoryLimit>,
180    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
181    #[serde(skip_serializing_if = "Option::is_none", default)]
182    pub cpu_limit: Option<CpuLimit>,
183    /// Unix signal to send for graceful shutdown (default: SIGTERM)
184    #[serde(skip_serializing_if = "Option::is_none", default)]
185    pub stop_signal: Option<StopConfig>,
186    /// Hook triggered when the daemon produces matching output
187    #[serde(skip_serializing_if = "Option::is_none", default)]
188    pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
189    /// Allocate a pseudo-terminal for the daemon process.
190    #[serde(skip_serializing_if = "Option::is_none", default)]
191    pub pty: Option<bool>,
192}
193
194impl Daemon {
195    /// Build RunOptions from persisted daemon state.
196    ///
197    /// Carries over all configuration fields from the daemon state.
198    /// Callers can override specific fields on the returned value.
199    pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
200        // Re-read on_output_hook from fresh config so restarts (retry, watch,
201        // cron) always pick up the current hook configuration.
202        // Use daemon.dir if available to handle daemons started via slugs
203        // whose project directory is not in the supervisor's cwd ancestry.
204        let on_output_hook = self
205            .dir
206            .as_deref()
207            .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
208            .or_else(|| crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces().ok())
209            .and_then(|pt| {
210                pt.daemons
211                    .get(&self.id)
212                    .and_then(|d| d.hooks.as_ref())
213                    .and_then(|h| h.on_output.clone())
214            });
215
216        RunOptions {
217            id: self.id.clone(),
218            cmd,
219            run: self.run.clone(),
220            force: false,
221            shell_pid: self.shell_pid,
222            dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
223            autostop: self.autostop,
224            cron_schedule: self.cron_schedule.clone(),
225            cron_retrigger: self.cron_retrigger,
226            cron_immediate: self.cron_immediate,
227            retry: self.retry,
228            retry_count: self.retry_count,
229            ready_delay: self.ready_delay,
230            ready_output: self.ready_output.clone(),
231            ready_http: self.ready_http.clone(),
232            ready_port: self.ready_port,
233            ready_cmd: self.ready_cmd.clone(),
234            port: self.port.clone(),
235            wait_ready: false,
236            depends: self.depends.clone(),
237            env: self.env.clone(),
238            watch: self.watch.clone(),
239            watch_mode: self.watch_mode,
240            watch_base_dir: self.watch_base_dir.clone(),
241            mise: self.mise,
242            slug: self.slug.clone(),
243            proxy: self.proxy,
244            user: self.user.clone(),
245            memory_limit: self.memory_limit,
246            cpu_limit: self.cpu_limit,
247            stop_signal: self.stop_signal,
248            on_output_hook,
249            pty: self.pty,
250        }
251    }
252}
253
254impl Display for Daemon {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        write!(f, "{}", self.id.qualified())
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_valid_daemon_ids() {
266        // Short IDs
267        assert!(is_valid_daemon_id("myapp"));
268        assert!(is_valid_daemon_id("my-app"));
269        assert!(is_valid_daemon_id("my_app"));
270        assert!(is_valid_daemon_id("my.app"));
271        assert!(is_valid_daemon_id("MyApp123"));
272
273        // Qualified IDs (namespace/short_id)
274        assert!(is_valid_daemon_id("project/api"));
275        assert!(is_valid_daemon_id("global/web"));
276        assert!(is_valid_daemon_id("my-project/my-app"));
277    }
278
279    #[test]
280    fn test_invalid_daemon_ids() {
281        // Empty
282        assert!(!is_valid_daemon_id(""));
283
284        // Multiple slashes (invalid qualified format)
285        assert!(!is_valid_daemon_id("a/b/c"));
286        assert!(!is_valid_daemon_id("../etc/passwd"));
287
288        // Invalid qualified format (empty parts)
289        assert!(!is_valid_daemon_id("/api"));
290        assert!(!is_valid_daemon_id("project/"));
291
292        // Backslashes
293        assert!(!is_valid_daemon_id("foo\\bar"));
294
295        // Parent directory reference
296        assert!(!is_valid_daemon_id(".."));
297        assert!(!is_valid_daemon_id("foo..bar"));
298
299        // Double dash (reserved for path encoding)
300        assert!(!is_valid_daemon_id("my--app"));
301        assert!(!is_valid_daemon_id("project--api"));
302        assert!(!is_valid_daemon_id("--app"));
303        assert!(!is_valid_daemon_id("app--"));
304
305        // Spaces
306        assert!(!is_valid_daemon_id("my app"));
307        assert!(!is_valid_daemon_id(" myapp"));
308        assert!(!is_valid_daemon_id("myapp "));
309
310        // Current directory
311        assert!(!is_valid_daemon_id("."));
312
313        // Control characters
314        assert!(!is_valid_daemon_id("my\x00app"));
315        assert!(!is_valid_daemon_id("my\napp"));
316        assert!(!is_valid_daemon_id("my\tapp"));
317
318        // Non-ASCII
319        assert!(!is_valid_daemon_id("myäpp"));
320        assert!(!is_valid_daemon_id("приложение"));
321
322        // Unsupported punctuation under DaemonId rules
323        assert!(!is_valid_daemon_id("app@host"));
324        assert!(!is_valid_daemon_id("app:8080"));
325    }
326}