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}