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