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    pub autostop: bool,
66    #[serde(skip_serializing_if = "Option::is_none", default)]
67    pub cron_schedule: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    pub cron_retrigger: Option<CronRetrigger>,
70    #[serde(skip_serializing_if = "Option::is_none", default)]
71    pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub last_exit_success: Option<bool>,
74    #[serde(default)]
75    pub retry: u32,
76    #[serde(default)]
77    pub retry_count: u32,
78    #[serde(skip_serializing_if = "Option::is_none", default)]
79    pub ready_delay: Option<u64>,
80    #[serde(skip_serializing_if = "Option::is_none", default)]
81    pub ready_output: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none", default)]
83    pub ready_http: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none", default)]
85    pub ready_port: Option<u16>,
86    #[serde(skip_serializing_if = "Vec::is_empty", default)]
87    pub depends: Vec<String>,
88}
89
90#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
91pub struct RunOptions {
92    pub id: String,
93    pub cmd: Vec<String>,
94    pub force: bool,
95    pub shell_pid: Option<u32>,
96    pub dir: PathBuf,
97    pub autostop: bool,
98    pub cron_schedule: Option<String>,
99    pub cron_retrigger: Option<CronRetrigger>,
100    pub retry: u32,
101    pub retry_count: u32,
102    pub ready_delay: Option<u64>,
103    pub ready_output: Option<String>,
104    pub ready_http: Option<String>,
105    pub ready_port: Option<u16>,
106    pub wait_ready: bool,
107    #[serde(skip_serializing_if = "Vec::is_empty", default)]
108    pub depends: Vec<String>,
109}
110
111impl Display for Daemon {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "{}", self.id)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_valid_daemon_ids() {
123        assert!(is_valid_daemon_id("myapp"));
124        assert!(is_valid_daemon_id("my-app"));
125        assert!(is_valid_daemon_id("my_app"));
126        assert!(is_valid_daemon_id("my.app"));
127        assert!(is_valid_daemon_id("MyApp123"));
128        assert!(is_valid_daemon_id("app@host"));
129        assert!(is_valid_daemon_id("app:8080"));
130    }
131
132    #[test]
133    fn test_invalid_daemon_ids() {
134        // Empty
135        assert!(!is_valid_daemon_id(""));
136
137        // Path separators
138        assert!(!is_valid_daemon_id("../etc/passwd"));
139        assert!(!is_valid_daemon_id("foo/bar"));
140        assert!(!is_valid_daemon_id("foo\\bar"));
141
142        // Parent directory reference
143        assert!(!is_valid_daemon_id(".."));
144        assert!(!is_valid_daemon_id("foo..bar"));
145
146        // Spaces
147        assert!(!is_valid_daemon_id("my app"));
148        assert!(!is_valid_daemon_id(" myapp"));
149        assert!(!is_valid_daemon_id("myapp "));
150
151        // Current directory
152        assert!(!is_valid_daemon_id("."));
153
154        // Control characters
155        assert!(!is_valid_daemon_id("my\x00app"));
156        assert!(!is_valid_daemon_id("my\napp"));
157        assert!(!is_valid_daemon_id("my\tapp"));
158
159        // Non-ASCII
160        assert!(!is_valid_daemon_id("myäpp"));
161        assert!(!is_valid_daemon_id("приложение"));
162    }
163
164    #[test]
165    fn test_validate_daemon_id_error_messages() {
166        assert!(validate_daemon_id("myapp").is_ok());
167
168        assert!(
169            validate_daemon_id("")
170                .unwrap_err()
171                .to_string()
172                .contains("daemon ID cannot be empty")
173        );
174        assert!(
175            validate_daemon_id("foo/bar")
176                .unwrap_err()
177                .to_string()
178                .contains("contains path separator")
179        );
180        assert!(
181            validate_daemon_id("foo\\bar")
182                .unwrap_err()
183                .to_string()
184                .contains("contains path separator")
185        );
186        assert!(
187            validate_daemon_id("..")
188                .unwrap_err()
189                .to_string()
190                .contains("contains parent directory reference")
191        );
192        assert!(
193            validate_daemon_id("my app")
194                .unwrap_err()
195                .to_string()
196                .contains("contains spaces")
197        );
198        assert!(
199            validate_daemon_id(".")
200                .unwrap_err()
201                .to_string()
202                .contains("daemon ID cannot be '.'")
203        );
204        assert!(
205            validate_daemon_id("my\x00app")
206                .unwrap_err()
207                .to_string()
208                .contains("non-printable or non-ASCII")
209        );
210    }
211}