command-stream 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ //! Shell quoting utilities for command-stream
2
+ //!
3
+ //! This module provides functions for safely quoting values for shell usage,
4
+ //! preventing command injection and ensuring proper argument handling.
5
+
6
+ /// Quote a value for safe shell usage
7
+ ///
8
+ /// This function quotes strings appropriately for use in shell commands,
9
+ /// handling special characters and edge cases.
10
+ ///
11
+ /// # Examples
12
+ ///
13
+ /// ```
14
+ /// use command_stream::quote::quote;
15
+ ///
16
+ /// // Safe characters are passed through unchanged
17
+ /// assert_eq!(quote("hello"), "hello");
18
+ /// assert_eq!(quote("/path/to/file"), "/path/to/file");
19
+ ///
20
+ /// // Special characters are quoted
21
+ /// assert_eq!(quote("hello world"), "'hello world'");
22
+ ///
23
+ /// // Single quotes in strings are escaped
24
+ /// assert_eq!(quote("it's"), "'it'\\''s'");
25
+ ///
26
+ /// // Empty strings are quoted
27
+ /// assert_eq!(quote(""), "''");
28
+ /// ```
29
+ pub fn quote(value: &str) -> String {
30
+ if value.is_empty() {
31
+ return "''".to_string();
32
+ }
33
+
34
+ // If already properly quoted with single quotes, check if we can use as-is
35
+ if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
36
+ let inner = &value[1..value.len() - 1];
37
+ if !inner.contains('\'') {
38
+ return value.to_string();
39
+ }
40
+ }
41
+
42
+ // If already double-quoted, wrap in single quotes
43
+ if value.starts_with('"') && value.ends_with('"') && value.len() > 2 {
44
+ return format!("'{}'", value);
45
+ }
46
+
47
+ // Check if the string needs quoting at all
48
+ // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus, at
49
+ let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
50
+
51
+ if safe_pattern.is_match(value) {
52
+ return value.to_string();
53
+ }
54
+
55
+ // Default behavior: wrap in single quotes and escape any internal single quotes
56
+ // The shell escape sequence for a single quote inside single quotes is: '\''
57
+ // This ends the single quote, adds an escaped single quote, and starts single quotes again
58
+ format!("'{}'", value.replace('\'', "'\\''"))
59
+ }
60
+
61
+ /// Quote multiple values and join them with spaces
62
+ ///
63
+ /// Convenience function for quoting a list of arguments.
64
+ ///
65
+ /// # Examples
66
+ ///
67
+ /// ```
68
+ /// use command_stream::quote::quote_all;
69
+ ///
70
+ /// let args = vec!["echo", "hello world", "test"];
71
+ /// assert_eq!(quote_all(&args), "echo 'hello world' test");
72
+ /// ```
73
+ pub fn quote_all(values: &[&str]) -> String {
74
+ values
75
+ .iter()
76
+ .map(|v| quote(v))
77
+ .collect::<Vec<_>>()
78
+ .join(" ")
79
+ }
80
+
81
+ /// Check if a string needs quoting for shell usage
82
+ ///
83
+ /// Returns true if the string contains characters that would be interpreted
84
+ /// specially by the shell.
85
+ ///
86
+ /// # Examples
87
+ ///
88
+ /// ```
89
+ /// use command_stream::quote::needs_quoting;
90
+ ///
91
+ /// assert!(!needs_quoting("hello"));
92
+ /// assert!(needs_quoting("hello world"));
93
+ /// assert!(needs_quoting("$PATH"));
94
+ /// ```
95
+ pub fn needs_quoting(value: &str) -> bool {
96
+ if value.is_empty() {
97
+ return true;
98
+ }
99
+
100
+ let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
101
+ !safe_pattern.is_match(value)
102
+ }
103
+
104
+ #[cfg(test)]
105
+ mod tests {
106
+ use super::*;
107
+
108
+ #[test]
109
+ fn test_quote_empty() {
110
+ assert_eq!(quote(""), "''");
111
+ }
112
+
113
+ #[test]
114
+ fn test_quote_safe_chars() {
115
+ assert_eq!(quote("hello"), "hello");
116
+ assert_eq!(quote("/path/to/file"), "/path/to/file");
117
+ assert_eq!(quote("file.txt"), "file.txt");
118
+ assert_eq!(quote("key=value"), "key=value");
119
+ assert_eq!(quote("user@host"), "user@host");
120
+ }
121
+
122
+ #[test]
123
+ fn test_quote_special_chars() {
124
+ assert_eq!(quote("hello world"), "'hello world'");
125
+ assert_eq!(quote("it's"), "'it'\\''s'");
126
+ assert_eq!(quote("$var"), "'$var'");
127
+ assert_eq!(quote("test*"), "'test*'");
128
+ }
129
+
130
+ #[test]
131
+ fn test_quote_already_quoted() {
132
+ assert_eq!(quote("'already quoted'"), "'already quoted'");
133
+ assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
134
+ }
135
+
136
+ #[test]
137
+ fn test_quote_all() {
138
+ let args = vec!["echo", "hello world", "test"];
139
+ assert_eq!(quote_all(&args), "echo 'hello world' test");
140
+ }
141
+
142
+ #[test]
143
+ fn test_needs_quoting() {
144
+ assert!(!needs_quoting("hello"));
145
+ assert!(!needs_quoting("/path/to/file"));
146
+ assert!(needs_quoting("hello world"));
147
+ assert!(needs_quoting("$PATH"));
148
+ assert!(needs_quoting(""));
149
+ assert!(needs_quoting("test*"));
150
+ }
151
+
152
+ #[test]
153
+ fn test_quote_with_newlines() {
154
+ assert_eq!(quote("line1\nline2"), "'line1\nline2'");
155
+ }
156
+
157
+ #[test]
158
+ fn test_quote_with_tabs() {
159
+ assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
160
+ }
161
+ }
@@ -0,0 +1,333 @@
1
+ //! Global state management for command-stream
2
+ //!
3
+ //! This module handles signal handlers, process tracking, and cleanup,
4
+ //! similar to the JavaScript $.state.mjs module.
5
+
6
+ use std::collections::HashSet;
7
+ use std::sync::atomic::{AtomicBool, Ordering};
8
+ use std::sync::Arc;
9
+ use tokio::sync::RwLock;
10
+
11
+ use crate::trace::trace_lazy;
12
+
13
+ /// Shell settings for controlling execution behavior
14
+ #[derive(Debug, Clone, Default)]
15
+ pub struct ShellSettings {
16
+ /// Exit immediately if a command exits with non-zero status (set -e)
17
+ pub errexit: bool,
18
+ /// Print commands as they are executed (set -v)
19
+ pub verbose: bool,
20
+ /// Print trace of commands (set -x)
21
+ pub xtrace: bool,
22
+ /// Return value of a pipeline is the status of the last command to exit with non-zero (set -o pipefail)
23
+ pub pipefail: bool,
24
+ /// Treat unset variables as an error (set -u)
25
+ pub nounset: bool,
26
+ /// Disable filename globbing (set -f)
27
+ pub noglob: bool,
28
+ /// Export all variables (set -a)
29
+ pub allexport: bool,
30
+ }
31
+
32
+ impl ShellSettings {
33
+ /// Create new shell settings with defaults
34
+ pub fn new() -> Self {
35
+ Self::default()
36
+ }
37
+
38
+ /// Reset all settings to their defaults
39
+ pub fn reset(&mut self) {
40
+ *self = Self::default();
41
+ }
42
+
43
+ /// Set a shell option by name
44
+ ///
45
+ /// Supports both short flags (e, v, x, u, f, a) and long names
46
+ pub fn set(&mut self, option: &str, value: bool) {
47
+ match option {
48
+ "e" | "errexit" => self.errexit = value,
49
+ "v" | "verbose" => self.verbose = value,
50
+ "x" | "xtrace" => self.xtrace = value,
51
+ "u" | "nounset" => self.nounset = value,
52
+ "f" | "noglob" => self.noglob = value,
53
+ "a" | "allexport" => self.allexport = value,
54
+ "o pipefail" | "pipefail" => self.pipefail = value,
55
+ _ => {
56
+ trace_lazy("ShellSettings", || {
57
+ format!("Unknown shell option: {}", option)
58
+ });
59
+ }
60
+ }
61
+ }
62
+
63
+ /// Enable a shell option
64
+ pub fn enable(&mut self, option: &str) {
65
+ self.set(option, true);
66
+ }
67
+
68
+ /// Disable a shell option
69
+ pub fn disable(&mut self, option: &str) {
70
+ self.set(option, false);
71
+ }
72
+ }
73
+
74
+ /// Global state for the command-stream library
75
+ pub struct GlobalState {
76
+ /// Current shell settings
77
+ shell_settings: RwLock<ShellSettings>,
78
+ /// Set of active process runner IDs
79
+ active_runners: RwLock<HashSet<u64>>,
80
+ /// Counter for generating runner IDs
81
+ next_runner_id: std::sync::atomic::AtomicU64,
82
+ /// Whether signal handlers are installed
83
+ signal_handlers_installed: AtomicBool,
84
+ /// Whether virtual commands are enabled
85
+ virtual_commands_enabled: AtomicBool,
86
+ /// Initial working directory
87
+ initial_cwd: RwLock<Option<std::path::PathBuf>>,
88
+ }
89
+
90
+ impl Default for GlobalState {
91
+ fn default() -> Self {
92
+ Self::new()
93
+ }
94
+ }
95
+
96
+ impl GlobalState {
97
+ /// Create a new global state
98
+ pub fn new() -> Self {
99
+ let initial_cwd = std::env::current_dir().ok();
100
+
101
+ GlobalState {
102
+ shell_settings: RwLock::new(ShellSettings::new()),
103
+ active_runners: RwLock::new(HashSet::new()),
104
+ next_runner_id: std::sync::atomic::AtomicU64::new(1),
105
+ signal_handlers_installed: AtomicBool::new(false),
106
+ virtual_commands_enabled: AtomicBool::new(true),
107
+ initial_cwd: RwLock::new(initial_cwd),
108
+ }
109
+ }
110
+
111
+ /// Get the current shell settings
112
+ pub async fn get_shell_settings(&self) -> ShellSettings {
113
+ self.shell_settings.read().await.clone()
114
+ }
115
+
116
+ /// Set shell settings
117
+ pub async fn set_shell_settings(&self, settings: ShellSettings) {
118
+ *self.shell_settings.write().await = settings;
119
+ }
120
+
121
+ /// Modify shell settings with a closure
122
+ pub async fn with_shell_settings<F>(&self, f: F)
123
+ where
124
+ F: FnOnce(&mut ShellSettings),
125
+ {
126
+ let mut settings = self.shell_settings.write().await;
127
+ f(&mut settings);
128
+ }
129
+
130
+ /// Enable a shell option
131
+ pub async fn enable_shell_option(&self, option: &str) {
132
+ self.shell_settings.write().await.enable(option);
133
+ }
134
+
135
+ /// Disable a shell option
136
+ pub async fn disable_shell_option(&self, option: &str) {
137
+ self.shell_settings.write().await.disable(option);
138
+ }
139
+
140
+ /// Register a new active runner and return its ID
141
+ pub async fn register_runner(&self) -> u64 {
142
+ let id = self
143
+ .next_runner_id
144
+ .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
145
+ self.active_runners.write().await.insert(id);
146
+
147
+ trace_lazy("GlobalState", || {
148
+ format!("Registered runner {}", id)
149
+ });
150
+
151
+ id
152
+ }
153
+
154
+ /// Unregister an active runner
155
+ pub async fn unregister_runner(&self, id: u64) {
156
+ self.active_runners.write().await.remove(&id);
157
+
158
+ trace_lazy("GlobalState", || {
159
+ format!("Unregistered runner {}", id)
160
+ });
161
+ }
162
+
163
+ /// Get the count of active runners
164
+ pub async fn active_runner_count(&self) -> usize {
165
+ self.active_runners.read().await.len()
166
+ }
167
+
168
+ /// Check if signal handlers are installed
169
+ pub fn are_signal_handlers_installed(&self) -> bool {
170
+ self.signal_handlers_installed.load(Ordering::SeqCst)
171
+ }
172
+
173
+ /// Mark signal handlers as installed
174
+ pub fn set_signal_handlers_installed(&self, installed: bool) {
175
+ self.signal_handlers_installed.store(installed, Ordering::SeqCst);
176
+ }
177
+
178
+ /// Check if virtual commands are enabled
179
+ pub fn are_virtual_commands_enabled(&self) -> bool {
180
+ self.virtual_commands_enabled.load(Ordering::SeqCst)
181
+ }
182
+
183
+ /// Enable virtual commands
184
+ pub fn enable_virtual_commands(&self) {
185
+ self.virtual_commands_enabled.store(true, Ordering::SeqCst);
186
+ trace_lazy("GlobalState", || "Virtual commands enabled".to_string());
187
+ }
188
+
189
+ /// Disable virtual commands
190
+ pub fn disable_virtual_commands(&self) {
191
+ self.virtual_commands_enabled.store(false, Ordering::SeqCst);
192
+ trace_lazy("GlobalState", || "Virtual commands disabled".to_string());
193
+ }
194
+
195
+ /// Get the initial working directory
196
+ pub async fn get_initial_cwd(&self) -> Option<std::path::PathBuf> {
197
+ self.initial_cwd.read().await.clone()
198
+ }
199
+
200
+ /// Reset global state to defaults
201
+ pub async fn reset(&self) {
202
+ // Reset shell settings
203
+ *self.shell_settings.write().await = ShellSettings::new();
204
+
205
+ // Clear active runners
206
+ self.active_runners.write().await.clear();
207
+
208
+ // Reset virtual commands flag
209
+ self.virtual_commands_enabled.store(true, Ordering::SeqCst);
210
+
211
+ // Don't reset signal handlers installed flag - that's managed separately
212
+
213
+ trace_lazy("GlobalState", || "Global state reset completed".to_string());
214
+ }
215
+
216
+ /// Restore working directory to initial
217
+ pub async fn restore_cwd(&self) -> std::io::Result<()> {
218
+ if let Some(ref initial) = *self.initial_cwd.read().await {
219
+ if initial.exists() {
220
+ std::env::set_current_dir(initial)?;
221
+ }
222
+ }
223
+ Ok(())
224
+ }
225
+ }
226
+
227
+ /// Global state singleton
228
+ static GLOBAL_STATE: std::sync::OnceLock<Arc<GlobalState>> = std::sync::OnceLock::new();
229
+
230
+ /// Get the global state instance
231
+ pub fn global_state() -> Arc<GlobalState> {
232
+ GLOBAL_STATE
233
+ .get_or_init(|| Arc::new(GlobalState::new()))
234
+ .clone()
235
+ }
236
+
237
+ /// Reset the global state (for testing)
238
+ pub async fn reset_global_state() {
239
+ global_state().reset().await;
240
+ }
241
+
242
+ /// Get current shell settings
243
+ pub async fn get_shell_settings() -> ShellSettings {
244
+ global_state().get_shell_settings().await
245
+ }
246
+
247
+ /// Enable a shell option globally
248
+ pub async fn set_shell_option(option: &str) {
249
+ global_state().enable_shell_option(option).await;
250
+ }
251
+
252
+ /// Disable a shell option globally
253
+ pub async fn unset_shell_option(option: &str) {
254
+ global_state().disable_shell_option(option).await;
255
+ }
256
+
257
+ #[cfg(test)]
258
+ mod tests {
259
+ use super::*;
260
+
261
+ #[test]
262
+ fn test_shell_settings_default() {
263
+ let settings = ShellSettings::new();
264
+ assert!(!settings.errexit);
265
+ assert!(!settings.verbose);
266
+ assert!(!settings.xtrace);
267
+ assert!(!settings.pipefail);
268
+ assert!(!settings.nounset);
269
+ }
270
+
271
+ #[test]
272
+ fn test_shell_settings_set() {
273
+ let mut settings = ShellSettings::new();
274
+
275
+ settings.set("e", true);
276
+ assert!(settings.errexit);
277
+
278
+ settings.set("errexit", false);
279
+ assert!(!settings.errexit);
280
+
281
+ settings.set("o pipefail", true);
282
+ assert!(settings.pipefail);
283
+ }
284
+
285
+ #[tokio::test]
286
+ async fn test_global_state_runners() {
287
+ let state = GlobalState::new();
288
+
289
+ let id1 = state.register_runner().await;
290
+ let id2 = state.register_runner().await;
291
+
292
+ assert_eq!(state.active_runner_count().await, 2);
293
+ assert!(id1 != id2);
294
+
295
+ state.unregister_runner(id1).await;
296
+ assert_eq!(state.active_runner_count().await, 1);
297
+
298
+ state.unregister_runner(id2).await;
299
+ assert_eq!(state.active_runner_count().await, 0);
300
+ }
301
+
302
+ #[tokio::test]
303
+ async fn test_global_state_virtual_commands() {
304
+ let state = GlobalState::new();
305
+
306
+ assert!(state.are_virtual_commands_enabled());
307
+
308
+ state.disable_virtual_commands();
309
+ assert!(!state.are_virtual_commands_enabled());
310
+
311
+ state.enable_virtual_commands();
312
+ assert!(state.are_virtual_commands_enabled());
313
+ }
314
+
315
+ #[tokio::test]
316
+ async fn test_global_state_reset() {
317
+ let state = GlobalState::new();
318
+
319
+ // Modify state
320
+ state.enable_shell_option("errexit").await;
321
+ state.register_runner().await;
322
+ state.disable_virtual_commands();
323
+
324
+ // Reset
325
+ state.reset().await;
326
+
327
+ // Verify reset
328
+ let settings = state.get_shell_settings().await;
329
+ assert!(!settings.errexit);
330
+ assert_eq!(state.active_runner_count().await, 0);
331
+ assert!(state.are_virtual_commands_enabled());
332
+ }
333
+ }