tcrm_task/tasks/config.rs
1use std::{collections::HashMap, sync::Arc};
2
3use crate::tasks::{error::TaskError, security::SecurityValidator};
4
5/// Configuration for a task to be executed.
6///
7/// `TaskConfig` defines all parameters needed to execute a system process securely.
8/// It includes the command, arguments, environment setup, timeouts, and monitoring options.
9///
10/// All fields are validated before execution to prevent:
11/// - Path traversal
12/// - Null byte
13///
14/// # Examples
15///
16/// ## Basic Command
17/// ```rust
18/// use tcrm_task::tasks::config::TaskConfig;
19///
20/// let config = TaskConfig::new("cmd")
21/// .args(["/C", "dir", "C:\\"]);
22/// ```
23///
24/// ## Complex Configuration
25/// ```rust
26/// use tcrm_task::tasks::config::{TaskConfig, StreamSource};
27/// use std::collections::HashMap;
28///
29/// let mut env = HashMap::new();
30/// env.insert("PATH".to_string(), "C:\\Windows\\System32".to_string());
31///
32/// let config = TaskConfig::new("cmd")
33/// .args(["/C", "echo", "Server started"])
34/// .working_dir("C:\\")
35/// .env(env)
36/// .timeout_ms(30000)
37/// .enable_stdin(true)
38/// .ready_indicator("Server started")
39/// .ready_indicator_source(StreamSource::Stdout);
40/// ```
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42#[derive(Debug, Clone)]
43pub struct TaskConfig {
44 /// The command or executable to run
45 pub command: String,
46
47 /// Arguments to pass to the command
48 pub args: Option<Vec<String>>,
49
50 /// Working directory for the command
51 pub working_dir: Option<String>,
52
53 /// Environment variables for the command
54 pub env: Option<HashMap<String, String>>,
55
56 /// Maximum allowed runtime in milliseconds
57 pub timeout_ms: Option<u64>,
58
59 /// Allow providing input to the task via stdin
60 pub enable_stdin: Option<bool>,
61
62 /// Optional string to indicate the task is ready (for long-running processes like servers)
63 pub ready_indicator: Option<String>,
64
65 /// Source of the ready indicator string (stdout/stderr)
66 pub ready_indicator_source: Option<StreamSource>,
67}
68
69pub type SharedTaskConfig = Arc<TaskConfig>;
70impl Default for TaskConfig {
71 fn default() -> Self {
72 TaskConfig {
73 command: String::new(),
74 args: None,
75 working_dir: None,
76 env: None,
77 timeout_ms: None,
78 enable_stdin: Some(false),
79 ready_indicator: None,
80 ready_indicator_source: Some(StreamSource::Stdout),
81 }
82 }
83}
84
85impl TaskConfig {
86 /// Create a new task configuration with the given command
87 ///
88 /// # Arguments
89 ///
90 /// * `command` - The executable command to run (e.g., "ls", "node", "python")
91 ///
92 /// # Examples
93 /// ```rust
94 /// use tcrm_task::tasks::config::TaskConfig;
95 ///
96 /// let config = TaskConfig::new("echo");
97 /// let config2 = TaskConfig::new("node".to_string());
98 /// ```
99 pub fn new(command: impl Into<String>) -> Self {
100 TaskConfig {
101 command: command.into(),
102 ..Default::default()
103 }
104 }
105
106 /// Set the arguments for the command
107 ///
108 /// # Arguments
109 ///
110 /// * `args` - Iterator of arguments to pass to the command
111 ///
112 /// # Examples
113 /// ```rust
114 /// use tcrm_task::tasks::config::TaskConfig;
115 ///
116 /// let config = TaskConfig::new("ls")
117 /// .args(["-la", "/tmp"]);
118 ///
119 /// let config2 = TaskConfig::new("cargo")
120 /// .args(vec!["build", "--release"]);
121 /// ```
122 #[must_use]
123 pub fn args<I, S>(mut self, args: I) -> Self
124 where
125 I: IntoIterator<Item = S>,
126 S: Into<String>,
127 {
128 self.args = Some(args.into_iter().map(Into::into).collect());
129 self
130 }
131
132 /// Set the working directory for the command
133 ///
134 /// The working directory must exist when the task is executed.
135 ///
136 /// # Arguments
137 ///
138 /// * `dir` - Path to the working directory
139 ///
140 /// # Examples
141 /// ```rust
142 /// use tcrm_task::tasks::config::TaskConfig;
143 ///
144 /// let config = TaskConfig::new("ls")
145 /// .working_dir("/tmp");
146 ///
147 /// let config2 = TaskConfig::new("cargo")
148 /// .working_dir("/path/to/project");
149 /// ```
150 #[must_use]
151 pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
152 self.working_dir = Some(dir.into());
153 self
154 }
155
156 /// Set environment variables for the command
157 ///
158 /// # Arguments
159 ///
160 /// * `env` - Iterator of (key, value) pairs for environment variables
161 ///
162 /// # Examples
163 /// ```rust
164 /// use tcrm_task::tasks::config::TaskConfig;
165 /// use std::collections::HashMap;
166 ///
167 /// // Using tuples
168 /// let config = TaskConfig::new("node")
169 /// .env([("NODE_ENV", "production"), ("PORT", "3000")]);
170 ///
171 /// // Using HashMap
172 /// let mut env = HashMap::new();
173 /// env.insert("RUST_LOG".to_string(), "debug".to_string());
174 /// let config2 = TaskConfig::new("cargo")
175 /// .env(env);
176 /// ```
177 #[must_use]
178 pub fn env<K, V, I>(mut self, env: I) -> Self
179 where
180 K: Into<String>,
181 V: Into<String>,
182 I: IntoIterator<Item = (K, V)>,
183 {
184 self.env = Some(env.into_iter().map(|(k, v)| (k.into(), v.into())).collect());
185 self
186 }
187
188 /// Set the maximum allowed runtime in milliseconds
189 ///
190 /// If the task runs longer than this timeout, it will be terminated.
191 ///
192 /// # Arguments
193 ///
194 /// * `timeout` - Timeout in milliseconds (must be > 0)
195 ///
196 /// # Examples
197 /// ```rust
198 /// use tcrm_task::tasks::config::TaskConfig;
199 ///
200 /// // 30 second timeout
201 /// let config = TaskConfig::new("long-running-task")
202 /// .timeout_ms(30000);
203 ///
204 /// // 5 minute timeout
205 /// let config2 = TaskConfig::new("build-script")
206 /// .timeout_ms(300000);
207 /// ```
208 #[must_use]
209 pub fn timeout_ms(mut self, timeout: u64) -> Self {
210 self.timeout_ms = Some(timeout);
211 self
212 }
213
214 /// Enable or disable stdin for the task
215 ///
216 /// When enabled, you can send input to the process via the stdin channel.
217 ///
218 /// # Arguments
219 ///
220 /// * `b` - Whether to enable stdin input
221 ///
222 /// # Examples
223 /// ```rust
224 /// use tcrm_task::tasks::config::TaskConfig;
225 ///
226 /// // Interactive command that needs input
227 /// let config = TaskConfig::new("python")
228 /// .args(["-i"])
229 /// .enable_stdin(true);
230 /// ```
231 #[must_use]
232 pub fn enable_stdin(mut self, b: bool) -> Self {
233 self.enable_stdin = Some(b);
234 self
235 }
236
237 /// Set the ready indicator for the task
238 ///
239 /// For long-running processes (like servers), this string indicates when
240 /// the process is ready to accept requests. When this string appears in
241 /// the process output, a Ready event will be emitted.
242 ///
243 /// # Arguments
244 ///
245 /// * `indicator` - String to look for in process output
246 ///
247 /// # Examples
248 /// ```rust
249 /// use tcrm_task::tasks::config::TaskConfig;
250 ///
251 /// let config = TaskConfig::new("my-server")
252 /// .ready_indicator("Server listening on port");
253 ///
254 /// let config2 = TaskConfig::new("database")
255 /// .ready_indicator("Database ready for connections");
256 /// ```
257 #[must_use]
258 pub fn ready_indicator(mut self, indicator: impl Into<String>) -> Self {
259 self.ready_indicator = Some(indicator.into());
260 self
261 }
262
263 /// Set the source of the ready indicator
264 ///
265 /// Specifies whether to look for the ready indicator in stdout or stderr.
266 ///
267 /// # Arguments
268 ///
269 /// * `source` - Stream source (Stdout or Stderr)
270 ///
271 /// # Examples
272 /// ```rust
273 /// use tcrm_task::tasks::config::{TaskConfig, StreamSource};
274 ///
275 /// let config = TaskConfig::new("my-server")
276 /// .ready_indicator("Ready")
277 /// .ready_indicator_source(StreamSource::Stderr);
278 /// ```
279 #[must_use]
280 pub fn ready_indicator_source(mut self, source: StreamSource) -> Self {
281 self.ready_indicator_source = Some(source);
282 self
283 }
284
285 /// Validate the configuration
286 ///
287 /// Validates all configuration parameters for security and correctness.
288 /// This method should be called before executing the task to ensure
289 /// safe operation.
290 ///
291 /// # Validation Checks
292 ///
293 /// - **Command**: Must not be empty, contain shell injection patterns, or exceed length limits
294 /// - **Arguments**: Must not contain null bytes or shell injection patterns
295 /// - **Working Directory**: Must exist and be a valid directory
296 /// - **Environment Variables**: Keys must not contain spaces, '=', or null bytes
297 /// - **Timeout**: Must be greater than 0 if specified
298 /// - **Ready Indicator**: Must not be empty if specified
299 ///
300 /// # Returns
301 ///
302 /// - `Ok(())` if the configuration is valid
303 /// - `Err(TaskError::InvalidConfiguration)` with details if validation fails
304 ///
305 /// # Errors
306 ///
307 /// Returns a [`TaskError`] if any validation check fails:
308 /// - [`TaskError::InvalidConfiguration`] for configuration errors
309 /// - [`TaskError::IO`] for working directory validation failures
310 ///
311 /// # Examples
312 ///
313 /// ```rust
314 /// use tcrm_task::tasks::config::TaskConfig;
315 ///
316 /// // Valid config
317 /// let config = TaskConfig::new("echo")
318 /// .args(["hello", "world"]);
319 /// assert!(config.validate().is_ok());
320 ///
321 /// // Invalid config (empty command)
322 /// let config = TaskConfig::new("");
323 /// assert!(config.validate().is_err());
324 ///
325 /// // Invalid config (zero timeout)
326 /// let config = TaskConfig::new("sleep")
327 /// .timeout_ms(0);
328 /// assert!(config.validate().is_err());
329 /// ```
330 pub fn validate(&self) -> Result<(), TaskError> {
331 // Validate command
332 SecurityValidator::validate_command(&self.command)?;
333
334 // Validate ready_indicator
335 if let Some(indicator) = &self.ready_indicator
336 && indicator.is_empty()
337 {
338 return Err(TaskError::InvalidConfiguration(
339 "ready_indicator cannot be empty string".to_string(),
340 ));
341 }
342
343 // Validate arguments
344 if let Some(args) = &self.args {
345 SecurityValidator::validate_args(args)?;
346 }
347
348 // Validate working directory
349 if let Some(dir) = &self.working_dir {
350 SecurityValidator::validate_working_dir(dir)?;
351 }
352
353 // Validate environment variables
354 if let Some(env) = &self.env {
355 SecurityValidator::validate_env_vars(env)?;
356 }
357
358 // Validate timeout
359 if let Some(timeout) = self.timeout_ms
360 && timeout == 0
361 {
362 return Err(TaskError::InvalidConfiguration(
363 "Timeout must be greater than 0".to_string(),
364 ));
365 }
366
367 Ok(())
368 }
369}
370
371/// Specifies the source stream for output monitoring
372///
373/// Used with ready indicators to specify whether to monitor stdout or stderr
374/// for the ready signal from long-running processes.
375///
376/// # Examples
377///
378/// ```rust
379/// use tcrm_task::tasks::config::{TaskConfig, StreamSource};
380///
381/// // Monitor stdout for ready signal
382/// let config = TaskConfig::new("web-server")
383/// .ready_indicator("Server ready")
384/// .ready_indicator_source(StreamSource::Stdout);
385///
386/// // Monitor stderr for ready signal
387/// let config2 = TaskConfig::new("database")
388/// .ready_indicator("Ready for connections")
389/// .ready_indicator_source(StreamSource::Stderr);
390/// ```
391#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
392#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
393#[derive(Debug, Clone, PartialEq)]
394pub enum StreamSource {
395 /// Standard output stream
396 Stdout = 0,
397 /// Standard error stream
398 Stderr = 1,
399}
400impl Default for StreamSource {
401 fn default() -> Self {
402 Self::Stdout
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use std::{collections::HashMap, env::temp_dir};
409
410 use crate::tasks::{config::TaskConfig, error::TaskError};
411
412 #[test]
413 fn validation() {
414 // Valid config
415 let config = TaskConfig::new("echo").args(["hello"]);
416 assert!(config.validate().is_ok());
417
418 // Empty command should fail
419 let config = TaskConfig::new("");
420 assert!(matches!(
421 config.validate(),
422 Err(TaskError::InvalidConfiguration(_))
423 ));
424
425 // Command with leading/trailing whitespace should fail
426 let config = TaskConfig::new(" echo ");
427 assert!(matches!(
428 config.validate(),
429 Err(TaskError::InvalidConfiguration(_))
430 ));
431
432 // Command exceeding max length should fail
433 let long_cmd = "a".repeat(4097);
434 let config = TaskConfig::new(long_cmd);
435 assert!(matches!(
436 config.validate(),
437 Err(TaskError::InvalidConfiguration(_))
438 ));
439
440 // Zero timeout should fail
441 let config = TaskConfig::new("echo").timeout_ms(0);
442 assert!(matches!(
443 config.validate(),
444 Err(TaskError::InvalidConfiguration(_))
445 ));
446
447 // Valid timeout should pass
448 let config = TaskConfig::new("echo").timeout_ms(30);
449 assert!(config.validate().is_ok());
450
451 // Arguments with empty string should fail
452 let config = TaskConfig::new("echo").args([""]);
453 assert!(matches!(
454 config.validate(),
455 Err(TaskError::InvalidConfiguration(_))
456 ));
457
458 // Argument with leading/trailing whitespace should fail
459 let config = TaskConfig::new("echo").args([" hello "]);
460 assert!(matches!(
461 config.validate(),
462 Err(TaskError::InvalidConfiguration(_))
463 ));
464
465 // Argument exceeding max length should fail
466 let long_arg = "a".repeat(4097);
467 let config = TaskConfig::new("echo").args([long_arg]);
468 assert!(matches!(
469 config.validate(),
470 Err(TaskError::InvalidConfiguration(_))
471 ));
472
473 // Working directory that does not exist should fail
474 let config = TaskConfig::new("echo").working_dir("/non/existent/dir");
475 assert!(matches!(
476 config.validate(),
477 Err(TaskError::InvalidConfiguration(_))
478 ));
479
480 // Working directory with temp dir should pass
481 let dir = temp_dir();
482 let config = TaskConfig::new("echo").working_dir(dir.as_path().to_str().unwrap());
483 assert!(config.validate().is_ok());
484
485 // Working directory with whitespace should fail
486 let dir = temp_dir();
487 let dir_str = format!(" {} ", dir.as_path().to_str().unwrap());
488 let config = TaskConfig::new("echo").working_dir(&dir_str);
489 assert!(matches!(
490 config.validate(),
491 Err(TaskError::InvalidConfiguration(_))
492 ));
493
494 // Environment variable with empty key should fail
495 let mut env = HashMap::new();
496 env.insert(String::new(), "value".to_string());
497 let config = TaskConfig::new("echo").env(env);
498 assert!(matches!(
499 config.validate(),
500 Err(TaskError::InvalidConfiguration(_))
501 ));
502
503 // Environment variable with space in key should fail
504 let mut env = HashMap::new();
505 env.insert("KEY WITH SPACE".to_string(), "value".to_string());
506 let config = TaskConfig::new("echo").env(env);
507 assert!(matches!(
508 config.validate(),
509 Err(TaskError::InvalidConfiguration(_))
510 ));
511
512 // Environment variable with '=' in key should fail
513 let mut env = HashMap::new();
514 env.insert("KEY=BAD".to_string(), "value".to_string());
515 let config = TaskConfig::new("echo").env(env);
516 assert!(matches!(
517 config.validate(),
518 Err(TaskError::InvalidConfiguration(_))
519 ));
520
521 // Environment variable key exceeding max length should fail
522 let mut env = HashMap::new();
523 env.insert("A".repeat(1025), "value".to_string());
524 let config = TaskConfig::new("echo").env(env);
525 assert!(matches!(
526 config.validate(),
527 Err(TaskError::InvalidConfiguration(_))
528 ));
529
530 // Environment variable value with whitespace should fail
531 let mut env = HashMap::new();
532 env.insert("KEY".to_string(), " value ".to_string());
533 let config = TaskConfig::new("echo").env(env);
534 assert!(matches!(
535 config.validate(),
536 Err(TaskError::InvalidConfiguration(_))
537 ));
538
539 // Environment variable value exceeding max length should fail
540 let mut env = HashMap::new();
541 env.insert("KEY".to_string(), "A".repeat(4097));
542 let config = TaskConfig::new("echo").env(env);
543 assert!(matches!(
544 config.validate(),
545 Err(TaskError::InvalidConfiguration(_))
546 ));
547
548 // Environment variable key/value valid should pass
549 let mut env = HashMap::new();
550 env.insert("KEY".to_string(), "some value".to_string());
551 let config = TaskConfig::new("echo").env(env);
552 assert!(config.validate().is_ok());
553
554 // ready_indicator: empty string should fail
555 let mut config = TaskConfig::new("echo");
556 config.ready_indicator = Some(String::new());
557 assert!(matches!(
558 config.validate(),
559 Err(TaskError::InvalidConfiguration(_))
560 ));
561
562 // ready_indicator: leading/trailing spaces should pass
563 let mut config = TaskConfig::new("echo");
564 config.ready_indicator = Some(" READY ".to_string());
565 assert!(config.validate().is_ok());
566
567 // ready_indicator: normal string should pass
568 let mut config = TaskConfig::new("echo");
569 config.ready_indicator = Some("READY".to_string());
570 assert!(config.validate().is_ok());
571 }
572 #[test]
573 fn config_builder() {
574 let config = TaskConfig::new("cargo")
575 .args(["build", "--release"])
576 .working_dir("/home/user/project")
577 .env([("RUST_LOG", "debug"), ("CARGO_TARGET_DIR", "target")])
578 .timeout_ms(300)
579 .enable_stdin(true);
580
581 assert_eq!(config.command, "cargo");
582 assert_eq!(
583 config.args,
584 Some(vec!["build".to_string(), "--release".to_string()])
585 );
586 assert_eq!(config.working_dir, Some("/home/user/project".to_string()));
587 assert!(config.env.is_some());
588 assert_eq!(config.timeout_ms, Some(300));
589 assert_eq!(config.enable_stdin, Some(true));
590 }
591}