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, WatchMode};
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    /// The first port the process is actually listening on (detected at runtime via listeners crate).
99    /// This is the source of truth for the reverse proxy. Cleared when the daemon stops.
100    #[serde(skip_serializing_if = "Option::is_none", default)]
101    pub active_port: Option<u16>,
102    /// Optional stable slug alias for this daemon (used in proxy URLs and CLI commands).
103    #[serde(skip_serializing_if = "Option::is_none", default)]
104    pub slug: Option<String>,
105    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
106    #[serde(skip_serializing_if = "Option::is_none", default)]
107    pub proxy: Option<bool>,
108    #[serde(default)]
109    pub auto_bump_port: bool,
110    #[serde(default)]
111    pub port_bump_attempts: u32,
112    #[serde(skip_serializing_if = "Vec::is_empty", default)]
113    pub depends: Vec<DaemonId>,
114    #[serde(skip_serializing_if = "Option::is_none", default)]
115    pub env: Option<IndexMap<String, String>>,
116    #[serde(skip_serializing_if = "Vec::is_empty", default)]
117    pub watch: Vec<String>,
118    #[serde(default)]
119    pub watch_mode: WatchMode,
120    #[serde(skip_serializing_if = "Option::is_none", default)]
121    pub watch_base_dir: Option<PathBuf>,
122    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
123    ///
124    /// # Schema compatibility note
125    /// This field changed from `bool` to `Option<bool>` with `skip_serializing_if = "Option::is_none"`.
126    /// - **Upgrade (old → new):** safe — old files contain `mise = true/false`, which deserialize
127    ///   correctly as `Some(true)` / `Some(false)`.
128    /// - **Downgrade (new → old):** if `mise` is `None` (inherit global), the key is omitted from
129    ///   the state file. An old binary reads the missing key as `false`, ignoring `general.mise = true`.
130    ///   Any daemon that relied on the global setting would silently stop using mise after a downgrade.
131    #[serde(skip_serializing_if = "Option::is_none", default)]
132    pub mise: Option<bool>,
133    /// Unix user to run this daemon as.
134    #[serde(skip_serializing_if = "Option::is_none", default)]
135    pub user: Option<String>,
136    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
137    #[serde(skip_serializing_if = "Option::is_none", default)]
138    pub memory_limit: Option<MemoryLimit>,
139    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
140    #[serde(skip_serializing_if = "Option::is_none", default)]
141    pub cpu_limit: Option<CpuLimit>,
142}
143
144#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
145pub struct RunOptions {
146    pub id: DaemonId,
147    pub cmd: Vec<String>,
148    pub force: bool,
149    pub shell_pid: Option<u32>,
150    pub dir: PathBuf,
151    pub autostop: bool,
152    pub cron_schedule: Option<String>,
153    pub cron_retrigger: Option<CronRetrigger>,
154    pub retry: u32,
155    pub retry_count: u32,
156    pub ready_delay: Option<u64>,
157    pub ready_output: Option<String>,
158    pub ready_http: Option<String>,
159    pub ready_port: Option<u16>,
160    pub ready_cmd: Option<String>,
161    pub expected_port: Vec<u16>,
162    pub auto_bump_port: bool,
163    pub port_bump_attempts: u32,
164    pub wait_ready: bool,
165    #[serde(skip_serializing_if = "Vec::is_empty", default)]
166    pub depends: Vec<DaemonId>,
167    #[serde(skip_serializing_if = "Option::is_none", default)]
168    pub env: Option<IndexMap<String, String>>,
169    #[serde(skip_serializing_if = "Vec::is_empty", default)]
170    pub watch: Vec<String>,
171    #[serde(default)]
172    pub watch_mode: WatchMode,
173    #[serde(skip_serializing_if = "Option::is_none", default)]
174    pub watch_base_dir: Option<PathBuf>,
175    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
176    ///
177    /// # Schema compatibility note
178    /// See `Daemon::mise` for downgrade implications when this field is `None`.
179    #[serde(skip_serializing_if = "Option::is_none", default)]
180    pub mise: Option<bool>,
181    /// Optional stable slug alias for this daemon.
182    #[serde(skip_serializing_if = "Option::is_none", default)]
183    pub slug: Option<String>,
184    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
185    #[serde(skip_serializing_if = "Option::is_none", default)]
186    pub proxy: Option<bool>,
187    /// Unix user to run this daemon as.
188    #[serde(skip_serializing_if = "Option::is_none", default)]
189    pub user: Option<String>,
190    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
191    #[serde(skip_serializing_if = "Option::is_none", default)]
192    pub memory_limit: Option<MemoryLimit>,
193    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
194    #[serde(skip_serializing_if = "Option::is_none", default)]
195    pub cpu_limit: Option<CpuLimit>,
196}
197
198impl Default for Daemon {
199    fn default() -> Self {
200        Self {
201            id: DaemonId::default(),
202            title: None,
203            pid: None,
204            shell_pid: None,
205            status: DaemonStatus::default(),
206            dir: None,
207            cmd: None,
208            autostop: false,
209            cron_schedule: None,
210            cron_retrigger: None,
211            last_cron_triggered: None,
212            last_exit_success: None,
213            retry: 0,
214            retry_count: 0,
215            ready_delay: None,
216            ready_output: None,
217            ready_http: None,
218            ready_port: None,
219            ready_cmd: None,
220            expected_port: Vec::new(),
221            resolved_port: Vec::new(),
222            active_port: None,
223            slug: None,
224            proxy: None,
225            auto_bump_port: false,
226            port_bump_attempts: 10,
227            depends: Vec::new(),
228            env: None,
229            watch: Vec::new(),
230            watch_mode: WatchMode::default(),
231            watch_base_dir: None,
232            mise: None,
233            user: None,
234            memory_limit: None,
235            cpu_limit: None,
236        }
237    }
238}
239
240impl Daemon {
241    /// Build RunOptions from persisted daemon state.
242    ///
243    /// Carries over all configuration fields from the daemon state.
244    /// Callers can override specific fields on the returned value.
245    pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
246        RunOptions {
247            id: self.id.clone(),
248            cmd,
249            force: false,
250            shell_pid: self.shell_pid,
251            dir: self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone()),
252            autostop: self.autostop,
253            cron_schedule: self.cron_schedule.clone(),
254            cron_retrigger: self.cron_retrigger,
255            retry: self.retry,
256            retry_count: self.retry_count,
257            ready_delay: self.ready_delay,
258            ready_output: self.ready_output.clone(),
259            ready_http: self.ready_http.clone(),
260            ready_port: self.ready_port,
261            ready_cmd: self.ready_cmd.clone(),
262            expected_port: self.expected_port.clone(),
263            auto_bump_port: self.auto_bump_port,
264            port_bump_attempts: self.port_bump_attempts,
265            wait_ready: false,
266            depends: self.depends.clone(),
267            env: self.env.clone(),
268            watch: self.watch.clone(),
269            watch_mode: self.watch_mode,
270            watch_base_dir: self.watch_base_dir.clone(),
271            mise: self.mise,
272            slug: self.slug.clone(),
273            proxy: self.proxy,
274            user: self.user.clone(),
275            memory_limit: self.memory_limit,
276            cpu_limit: self.cpu_limit,
277        }
278    }
279}
280
281impl Default for RunOptions {
282    fn default() -> Self {
283        Self {
284            id: DaemonId::default(),
285            cmd: Vec::new(),
286            force: false,
287            shell_pid: None,
288            dir: crate::env::CWD.clone(),
289            autostop: false,
290            cron_schedule: None,
291            cron_retrigger: None,
292            retry: 0,
293            retry_count: 0,
294            ready_delay: None,
295            ready_output: None,
296            ready_http: None,
297            ready_port: None,
298            ready_cmd: None,
299            expected_port: Vec::new(),
300            auto_bump_port: false,
301            port_bump_attempts: 10,
302            wait_ready: false,
303            depends: Vec::new(),
304            env: None,
305            watch: Vec::new(),
306            watch_mode: WatchMode::default(),
307            watch_base_dir: None,
308            mise: None,
309            slug: None,
310            proxy: None,
311            user: None,
312            memory_limit: None,
313            cpu_limit: None,
314        }
315    }
316}
317
318impl Display for Daemon {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        write!(f, "{}", self.id.qualified())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_valid_daemon_ids() {
330        // Short IDs
331        assert!(is_valid_daemon_id("myapp"));
332        assert!(is_valid_daemon_id("my-app"));
333        assert!(is_valid_daemon_id("my_app"));
334        assert!(is_valid_daemon_id("my.app"));
335        assert!(is_valid_daemon_id("MyApp123"));
336
337        // Qualified IDs (namespace/short_id)
338        assert!(is_valid_daemon_id("project/api"));
339        assert!(is_valid_daemon_id("global/web"));
340        assert!(is_valid_daemon_id("my-project/my-app"));
341    }
342
343    #[test]
344    fn test_invalid_daemon_ids() {
345        // Empty
346        assert!(!is_valid_daemon_id(""));
347
348        // Multiple slashes (invalid qualified format)
349        assert!(!is_valid_daemon_id("a/b/c"));
350        assert!(!is_valid_daemon_id("../etc/passwd"));
351
352        // Invalid qualified format (empty parts)
353        assert!(!is_valid_daemon_id("/api"));
354        assert!(!is_valid_daemon_id("project/"));
355
356        // Backslashes
357        assert!(!is_valid_daemon_id("foo\\bar"));
358
359        // Parent directory reference
360        assert!(!is_valid_daemon_id(".."));
361        assert!(!is_valid_daemon_id("foo..bar"));
362
363        // Double dash (reserved for path encoding)
364        assert!(!is_valid_daemon_id("my--app"));
365        assert!(!is_valid_daemon_id("project--api"));
366        assert!(!is_valid_daemon_id("--app"));
367        assert!(!is_valid_daemon_id("app--"));
368
369        // Spaces
370        assert!(!is_valid_daemon_id("my app"));
371        assert!(!is_valid_daemon_id(" myapp"));
372        assert!(!is_valid_daemon_id("myapp "));
373
374        // Current directory
375        assert!(!is_valid_daemon_id("."));
376
377        // Control characters
378        assert!(!is_valid_daemon_id("my\x00app"));
379        assert!(!is_valid_daemon_id("my\napp"));
380        assert!(!is_valid_daemon_id("my\tapp"));
381
382        // Non-ASCII
383        assert!(!is_valid_daemon_id("myäpp"));
384        assert!(!is_valid_daemon_id("приложение"));
385
386        // Unsupported punctuation under DaemonId rules
387        assert!(!is_valid_daemon_id("app@host"));
388        assert!(!is_valid_daemon_id("app:8080"));
389    }
390}