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