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