pitchfork_cli/
daemon.rs

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