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