Skip to main content

pitchfork_cli/
daemon.rs

1use crate::Result;
2use crate::daemon_status::DaemonStatus;
3use crate::error::DaemonIdError;
4use crate::pitchfork_toml::CronRetrigger;
5use indexmap::IndexMap;
6use std::fmt::Display;
7use std::path::PathBuf;
8
9/// Validates a daemon ID to ensure it's safe for use in file paths and IPC.
10///
11/// A valid daemon ID:
12/// - Is not empty
13/// - Does not contain path separators (`/` or `\`)
14/// - Does not contain parent directory references (`..`)
15/// - Does not contain spaces
16/// - Is not `.` (current directory)
17/// - Contains only printable ASCII characters
18///
19/// This validation prevents path traversal attacks when daemon IDs are used
20/// to construct log file paths or other filesystem operations.
21pub fn is_valid_daemon_id(id: &str) -> bool {
22    !id.is_empty()
23        && !id.contains('/')
24        && !id.contains('\\')
25        && !id.contains("..")
26        && !id.contains(' ')
27        && id != "."
28        && id.chars().all(|c| c.is_ascii() && !c.is_ascii_control())
29}
30
31/// Validates a daemon ID and returns a diagnostic error with context if invalid.
32pub fn validate_daemon_id(id: &str) -> Result<()> {
33    if id.is_empty() {
34        return Err(DaemonIdError::Empty.into());
35    }
36    if let Some(sep) = id.chars().find(|&c| c == '/' || c == '\\') {
37        return Err(DaemonIdError::PathSeparator {
38            id: id.to_string(),
39            sep,
40        }
41        .into());
42    }
43    if id.contains("..") {
44        return Err(DaemonIdError::ParentDirRef { id: id.to_string() }.into());
45    }
46    if id.contains(' ') {
47        return Err(DaemonIdError::ContainsSpace { id: id.to_string() }.into());
48    }
49    if id == "." {
50        return Err(DaemonIdError::CurrentDir.into());
51    }
52    if !id.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
53        return Err(DaemonIdError::InvalidChars { id: id.to_string() }.into());
54    }
55    Ok(())
56}
57
58#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
59pub struct Daemon {
60    pub id: String,
61    pub title: Option<String>,
62    pub pid: Option<u32>,
63    pub shell_pid: Option<u32>,
64    pub status: DaemonStatus,
65    pub dir: Option<PathBuf>,
66    #[serde(skip_serializing_if = "Option::is_none", default)]
67    pub cmd: Option<Vec<String>>,
68    pub autostop: bool,
69    #[serde(skip_serializing_if = "Option::is_none", default)]
70    pub cron_schedule: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    pub cron_retrigger: Option<CronRetrigger>,
73    #[serde(skip_serializing_if = "Option::is_none", default)]
74    pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
75    #[serde(skip_serializing_if = "Option::is_none", default)]
76    pub last_exit_success: Option<bool>,
77    #[serde(default)]
78    pub retry: u32,
79    #[serde(default)]
80    pub retry_count: u32,
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub ready_delay: Option<u64>,
83    #[serde(skip_serializing_if = "Option::is_none", default)]
84    pub ready_output: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none", default)]
86    pub ready_http: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none", default)]
88    pub ready_port: Option<u16>,
89    #[serde(skip_serializing_if = "Option::is_none", default)]
90    pub ready_cmd: Option<String>,
91    #[serde(skip_serializing_if = "Vec::is_empty", default)]
92    pub depends: Vec<String>,
93    #[serde(skip_serializing_if = "Option::is_none", default)]
94    pub env: Option<IndexMap<String, String>>,
95}
96
97#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
98pub struct RunOptions {
99    pub id: String,
100    pub cmd: Vec<String>,
101    pub force: bool,
102    pub shell_pid: Option<u32>,
103    pub dir: PathBuf,
104    pub autostop: bool,
105    pub cron_schedule: Option<String>,
106    pub cron_retrigger: Option<CronRetrigger>,
107    pub retry: u32,
108    pub retry_count: u32,
109    pub ready_delay: Option<u64>,
110    pub ready_output: Option<String>,
111    pub ready_http: Option<String>,
112    pub ready_port: Option<u16>,
113    pub ready_cmd: Option<String>,
114    pub wait_ready: bool,
115    #[serde(skip_serializing_if = "Vec::is_empty", default)]
116    pub depends: Vec<String>,
117    #[serde(skip_serializing_if = "Option::is_none", default)]
118    pub env: Option<IndexMap<String, String>>,
119}
120
121impl Display for Daemon {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        write!(f, "{}", self.id)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_valid_daemon_ids() {
133        assert!(is_valid_daemon_id("myapp"));
134        assert!(is_valid_daemon_id("my-app"));
135        assert!(is_valid_daemon_id("my_app"));
136        assert!(is_valid_daemon_id("my.app"));
137        assert!(is_valid_daemon_id("MyApp123"));
138        assert!(is_valid_daemon_id("app@host"));
139        assert!(is_valid_daemon_id("app:8080"));
140    }
141
142    #[test]
143    fn test_invalid_daemon_ids() {
144        // Empty
145        assert!(!is_valid_daemon_id(""));
146
147        // Path separators
148        assert!(!is_valid_daemon_id("../etc/passwd"));
149        assert!(!is_valid_daemon_id("foo/bar"));
150        assert!(!is_valid_daemon_id("foo\\bar"));
151
152        // Parent directory reference
153        assert!(!is_valid_daemon_id(".."));
154        assert!(!is_valid_daemon_id("foo..bar"));
155
156        // Spaces
157        assert!(!is_valid_daemon_id("my app"));
158        assert!(!is_valid_daemon_id(" myapp"));
159        assert!(!is_valid_daemon_id("myapp "));
160
161        // Current directory
162        assert!(!is_valid_daemon_id("."));
163
164        // Control characters
165        assert!(!is_valid_daemon_id("my\x00app"));
166        assert!(!is_valid_daemon_id("my\napp"));
167        assert!(!is_valid_daemon_id("my\tapp"));
168
169        // Non-ASCII
170        assert!(!is_valid_daemon_id("myäpp"));
171        assert!(!is_valid_daemon_id("приложение"));
172    }
173
174    #[test]
175    fn test_validate_daemon_id_error_messages() {
176        assert!(validate_daemon_id("myapp").is_ok());
177
178        assert!(
179            validate_daemon_id("")
180                .unwrap_err()
181                .to_string()
182                .contains("daemon ID cannot be empty")
183        );
184        assert!(
185            validate_daemon_id("foo/bar")
186                .unwrap_err()
187                .to_string()
188                .contains("contains path separator")
189        );
190        assert!(
191            validate_daemon_id("foo\\bar")
192                .unwrap_err()
193                .to_string()
194                .contains("contains path separator")
195        );
196        assert!(
197            validate_daemon_id("..")
198                .unwrap_err()
199                .to_string()
200                .contains("contains parent directory reference")
201        );
202        assert!(
203            validate_daemon_id("my app")
204                .unwrap_err()
205                .to_string()
206                .contains("contains spaces")
207        );
208        assert!(
209            validate_daemon_id(".")
210                .unwrap_err()
211                .to_string()
212                .contains("daemon ID cannot be '.'")
213        );
214        assert!(
215            validate_daemon_id("my\x00app")
216                .unwrap_err()
217                .to_string()
218                .contains("non-printable or non-ASCII")
219        );
220    }
221}