Skip to main content

update_kit/applier/
delegate.rs

1use std::sync::Arc;
2
3use crate::constants::{DEFAULT_DELEGATE_TIMEOUT_MS, MAX_COMMAND_OUTPUT_BYTES};
4use crate::errors::UpdateKitError;
5use crate::types::{ApplyProgress, ApplyResult, DelegateMode, PlanKind, UpdatePlan};
6use crate::utils::process::{CommandRunner, TokioCommandRunner};
7
8/// Options for delegate command execution.
9pub struct DelegateApplyOptions {
10    /// Whether to print the command or execute it. Defaults to `PrintOnly`.
11    pub mode: Option<DelegateMode>,
12    /// Timeout for command execution in milliseconds.
13    pub timeout_ms: Option<u64>,
14    /// Progress callback.
15    pub on_progress: Option<Box<dyn Fn(ApplyProgress) + Send + Sync>>,
16    /// Command runner for executing external processes.
17    pub cmd: Option<Arc<dyn CommandRunner>>,
18}
19
20/// Result of a delegate command operation.
21pub struct DelegateApplyResult {
22    pub kind: DelegateResultKind,
23}
24
25/// The kind of delegate result.
26pub enum DelegateResultKind {
27    /// Command was only printed, not executed.
28    PrintOnly { message: String },
29    /// Command was executed.
30    Executed {
31        exit_code: Option<i32>,
32        stdout: String,
33        stderr: String,
34    },
35}
36
37/// Safe list of commands that are allowed to be executed.
38const COMMAND_SAFELIST: &[&str] = &[
39    "npm", "npx", "brew", "apt", "apt-get", "yum", "dnf", "choco", "winget", "scoop",
40];
41
42/// Apply an update by delegating to a package manager command.
43///
44/// In `PrintOnly` mode (default), returns `ApplyResult::NeedsRestart` with the
45/// command string. In `Execute` mode, validates the command against a safelist,
46/// runs it, and returns success or failure based on exit code.
47pub async fn apply_delegate_update(
48    plan: &UpdatePlan,
49    options: Option<DelegateApplyOptions>,
50) -> ApplyResult {
51    let (command, mode_from_plan) = match &plan.kind {
52        PlanKind::DelegateCommand {
53            command, mode, ..
54        } => (command.clone(), *mode),
55        _ => {
56            return ApplyResult::Failed {
57                error: Box::new(UpdateKitError::ApplyFailed(
58                    "apply_delegate_update called with non-DelegateCommand plan".into(),
59                )),
60                rollback_succeeded: false,
61            };
62        }
63    };
64
65    let opts_mode = options.as_ref().and_then(|o| o.mode);
66    let mode = opts_mode.unwrap_or(mode_from_plan);
67
68    let command_str = command.join(" ");
69
70    match mode {
71        DelegateMode::PrintOnly => ApplyResult::NeedsRestart {
72            message: format!("Run the following command to update:\n  {command_str}"),
73        },
74        DelegateMode::Execute => {
75            execute_command(&command, &options, &plan.from_version, &plan.to_version).await
76        }
77    }
78}
79
80async fn execute_command(
81    command: &[String],
82    options: &Option<DelegateApplyOptions>,
83    from_version: &str,
84    to_version: &str,
85) -> ApplyResult {
86    if command.is_empty() {
87        return ApplyResult::Failed {
88            error: Box::new(UpdateKitError::CommandFailed("Empty command".into())),
89            rollback_succeeded: false,
90        };
91    }
92
93    let program = &command[0];
94
95    // Validate against safelist
96    if let Err(e) = validate_command(program) {
97        return ApplyResult::Failed {
98            error: Box::new(e),
99            rollback_succeeded: false,
100        };
101    }
102
103    let timeout_ms = options
104        .as_ref()
105        .and_then(|o| o.timeout_ms)
106        .unwrap_or(DEFAULT_DELEGATE_TIMEOUT_MS);
107
108    let cmd: Arc<dyn CommandRunner> = options
109        .as_ref()
110        .and_then(|o| o.cmd.clone())
111        .unwrap_or_else(|| Arc::new(TokioCommandRunner));
112
113    let progress_cb = options.as_ref().and_then(|o| o.on_progress.as_ref());
114
115    if let Some(cb) = progress_cb {
116        cb(ApplyProgress::Executing {
117            output: format!("Running: {}", command.join(" ")),
118            stream: crate::types::OutputStream::Stdout,
119        });
120    }
121
122    let args_refs: Vec<&str> = command[1..].iter().map(|s| s.as_str()).collect();
123
124    let result = tokio::time::timeout(
125        std::time::Duration::from_millis(timeout_ms),
126        cmd.run(program, &args_refs),
127    )
128    .await;
129
130    match result {
131        Ok(Ok(output)) => {
132            let exit_code = output.exit_code;
133            let stdout = truncate_string(&output.stdout, MAX_COMMAND_OUTPUT_BYTES);
134            let stderr = truncate_string(&output.stderr, MAX_COMMAND_OUTPUT_BYTES);
135            // Check for permission errors (npm EACCES)
136            if stderr.contains("EACCES") || stderr.contains("permission denied") {
137                return ApplyResult::Failed {
138                    error: Box::new(UpdateKitError::PermissionDenied(format!(
139                        "Permission error running {}: {}",
140                        command.join(" "),
141                        stderr.lines().next().unwrap_or(&stderr)
142                    ))),
143                    rollback_succeeded: false,
144                };
145            }
146
147            match exit_code {
148                Some(0) => ApplyResult::Success {
149                    from_version: from_version.to_string(),
150                    to_version: to_version.to_string(),
151                    post_action: crate::types::PostAction::SuggestRestart,
152                },
153                _ => ApplyResult::Failed {
154                    error: Box::new(UpdateKitError::CommandFailed(format!(
155                        "Command exited with code {:?}: {}",
156                        exit_code,
157                        if stderr.is_empty() { &stdout } else { &stderr }
158                    ))),
159                    rollback_succeeded: false,
160                },
161            }
162        }
163        Ok(Err(e)) => ApplyResult::Failed {
164            error: Box::new(e),
165            rollback_succeeded: false,
166        },
167        Err(_) => ApplyResult::Failed {
168            error: Box::new(UpdateKitError::CommandTimeout(timeout_ms)),
169            rollback_succeeded: false,
170        },
171    }
172}
173
174fn truncate_string(s: &str, max_bytes: usize) -> String {
175    if s.len() <= max_bytes {
176        s.to_string()
177    } else {
178        s[..max_bytes].to_string()
179    }
180}
181
182/// Validate that a command program is in the allowed safelist.
183pub fn validate_command(program: &str) -> Result<(), UpdateKitError> {
184    // Extract just the binary name (strip path)
185    let binary_name = program.rsplit('/').next().unwrap_or(program);
186    let binary_name = binary_name.rsplit('\\').next().unwrap_or(binary_name);
187
188    if COMMAND_SAFELIST.contains(&binary_name) {
189        Ok(())
190    } else {
191        Err(UpdateKitError::CommandFailed(format!(
192            "Command '{binary_name}' is not in the allowed safelist: {:?}",
193            COMMAND_SAFELIST
194        )))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::types::{Channel, PostAction};
202
203    fn make_delegate_plan(command: Vec<&str>, mode: DelegateMode) -> UpdatePlan {
204        UpdatePlan {
205            kind: PlanKind::DelegateCommand {
206                channel: Channel::NpmGlobal,
207                command: command.into_iter().map(String::from).collect(),
208                mode,
209            },
210            from_version: "1.0.0".into(),
211            to_version: "2.0.0".into(),
212            post_action: PostAction::SuggestRestart,
213        }
214    }
215
216    #[tokio::test]
217    async fn print_only_mode_returns_needs_restart() {
218        let plan = make_delegate_plan(vec!["npm", "update", "-g", "myapp"], DelegateMode::PrintOnly);
219
220        let result = apply_delegate_update(&plan, None).await;
221        match result {
222            ApplyResult::NeedsRestart { message } => {
223                assert!(message.contains("npm update -g myapp"));
224            }
225            other => panic!("Expected NeedsRestart, got: {other:?}"),
226        }
227    }
228
229    #[tokio::test]
230    async fn invalid_command_rejected() {
231        let plan = make_delegate_plan(vec!["rm", "-rf", "/"], DelegateMode::Execute);
232
233        let result = apply_delegate_update(&plan, None).await;
234        match result {
235            ApplyResult::Failed { error, .. } => {
236                assert_eq!(error.code(), "COMMAND_FAILED");
237                assert!(error.to_string().contains("safelist"));
238            }
239            other => panic!("Expected Failed, got: {other:?}"),
240        }
241    }
242
243    #[test]
244    fn safelist_validation_accepts_valid_commands() {
245        assert!(validate_command("npm").is_ok());
246        assert!(validate_command("brew").is_ok());
247        assert!(validate_command("apt-get").is_ok());
248        assert!(validate_command("choco").is_ok());
249        assert!(validate_command("winget").is_ok());
250        assert!(validate_command("scoop").is_ok());
251    }
252
253    #[test]
254    fn safelist_validation_rejects_invalid_commands() {
255        assert!(validate_command("rm").is_err());
256        assert!(validate_command("curl").is_err());
257        assert!(validate_command("sudo").is_err());
258        assert!(validate_command("sh").is_err());
259    }
260
261    #[test]
262    fn safelist_validation_strips_path() {
263        assert!(validate_command("/usr/bin/npm").is_ok());
264        assert!(validate_command("/usr/local/bin/brew").is_ok());
265    }
266
267    #[tokio::test]
268    async fn execute_mode_success() {
269        use crate::test_utils::MockCommandRunner;
270
271        let cmd = MockCommandRunner::new();
272        cmd.on(
273            "npm update -g myapp",
274            Ok(MockCommandRunner::success_output("updated to 2.0.0")),
275        );
276
277        let plan = make_delegate_plan(vec!["npm", "update", "-g", "myapp"], DelegateMode::Execute);
278        let opts = DelegateApplyOptions {
279            mode: None,
280            timeout_ms: Some(5000),
281            on_progress: None,
282            cmd: Some(Arc::new(cmd)),
283        };
284        let result = apply_delegate_update(&plan, Some(opts)).await;
285        match result {
286            ApplyResult::Success {
287                from_version,
288                to_version,
289                ..
290            } => {
291                assert_eq!(from_version, "1.0.0");
292                assert_eq!(to_version, "2.0.0");
293            }
294            other => panic!("Expected Success, got: {other:?}"),
295        }
296    }
297
298    #[tokio::test]
299    async fn execute_mode_nonzero_exit() {
300        use crate::test_utils::MockCommandRunner;
301
302        let cmd = MockCommandRunner::new();
303        cmd.on(
304            "npm update -g myapp",
305            Ok(MockCommandRunner::failure_output("npm ERR! 404")),
306        );
307
308        let plan = make_delegate_plan(vec!["npm", "update", "-g", "myapp"], DelegateMode::Execute);
309        let opts = DelegateApplyOptions {
310            mode: None,
311            timeout_ms: Some(5000),
312            on_progress: None,
313            cmd: Some(Arc::new(cmd)),
314        };
315        let result = apply_delegate_update(&plan, Some(opts)).await;
316        match result {
317            ApplyResult::Failed { error, .. } => {
318                assert_eq!(error.code(), "COMMAND_FAILED");
319            }
320            other => panic!("Expected Failed, got: {other:?}"),
321        }
322    }
323
324    #[tokio::test]
325    async fn execute_mode_permission_error_eacces() {
326        use crate::test_utils::MockCommandRunner;
327
328        let cmd = MockCommandRunner::new();
329        cmd.on(
330            "npm update -g myapp",
331            Ok(MockCommandRunner::failure_output(
332                "npm ERR! code EACCES\nnpm ERR! permission denied",
333            )),
334        );
335
336        let plan = make_delegate_plan(vec!["npm", "update", "-g", "myapp"], DelegateMode::Execute);
337        let opts = DelegateApplyOptions {
338            mode: None,
339            timeout_ms: Some(5000),
340            on_progress: None,
341            cmd: Some(Arc::new(cmd)),
342        };
343        let result = apply_delegate_update(&plan, Some(opts)).await;
344        match result {
345            ApplyResult::Failed { error, .. } => {
346                assert_eq!(error.code(), "PERMISSION_DENIED");
347            }
348            other => panic!("Expected Failed with PermissionDenied, got: {other:?}"),
349        }
350    }
351
352    #[tokio::test]
353    async fn execute_mode_command_spawn_error() {
354        use crate::test_utils::MockCommandRunner;
355
356        let cmd = MockCommandRunner::new();
357        // Don't register any response — will get CommandSpawnFailed
358
359        let plan = make_delegate_plan(vec!["brew", "upgrade", "myapp"], DelegateMode::Execute);
360        let opts = DelegateApplyOptions {
361            mode: None,
362            timeout_ms: Some(5000),
363            on_progress: None,
364            cmd: Some(Arc::new(cmd)),
365        };
366        let result = apply_delegate_update(&plan, Some(opts)).await;
367        match result {
368            ApplyResult::Failed { error, .. } => {
369                assert!(
370                    error.code() == "COMMAND_SPAWN_FAILED" || error.code() == "COMMAND_FAILED"
371                );
372            }
373            other => panic!("Expected Failed, got: {other:?}"),
374        }
375    }
376
377    #[tokio::test]
378    async fn options_mode_overrides_plan_mode() {
379        // Plan says Execute, but options say PrintOnly
380        let plan = make_delegate_plan(vec!["npm", "update", "-g", "myapp"], DelegateMode::Execute);
381        let opts = DelegateApplyOptions {
382            mode: Some(DelegateMode::PrintOnly),
383            timeout_ms: None,
384            on_progress: None,
385            cmd: None,
386        };
387        let result = apply_delegate_update(&plan, Some(opts)).await;
388        match result {
389            ApplyResult::NeedsRestart { message } => {
390                assert!(message.contains("npm update"));
391            }
392            other => panic!("Expected NeedsRestart, got: {other:?}"),
393        }
394    }
395
396    #[tokio::test]
397    async fn empty_command_fails() {
398        use crate::test_utils::MockCommandRunner;
399
400        let plan = UpdatePlan {
401            kind: PlanKind::DelegateCommand {
402                channel: Channel::NpmGlobal,
403                command: vec![],
404                mode: DelegateMode::Execute,
405            },
406            from_version: "1.0.0".into(),
407            to_version: "2.0.0".into(),
408            post_action: PostAction::None,
409        };
410        let cmd = MockCommandRunner::new();
411        let opts = DelegateApplyOptions {
412            mode: None,
413            timeout_ms: Some(5000),
414            on_progress: None,
415            cmd: Some(Arc::new(cmd)),
416        };
417        let result = apply_delegate_update(&plan, Some(opts)).await;
418        match result {
419            ApplyResult::Failed { error, .. } => {
420                assert_eq!(error.code(), "COMMAND_FAILED");
421            }
422            other => panic!("Expected Failed, got: {other:?}"),
423        }
424    }
425
426    #[test]
427    fn truncate_output_within_limit() {
428        let short = "hello";
429        assert_eq!(truncate_string(short, 100), "hello");
430    }
431
432    #[test]
433    fn truncate_output_exceeds_limit() {
434        let long = "a".repeat(200);
435        let truncated = truncate_string(&long, 100);
436        assert_eq!(truncated.len(), 100);
437    }
438
439    #[tokio::test]
440    async fn wrong_plan_type_fails() {
441        let plan = UpdatePlan {
442            kind: PlanKind::ManualInstall {
443                reason: "test".into(),
444                instructions: "test".into(),
445                download_url: None,
446            },
447            from_version: "1.0.0".into(),
448            to_version: "2.0.0".into(),
449            post_action: PostAction::None,
450        };
451
452        let result = apply_delegate_update(&plan, None).await;
453        match result {
454            ApplyResult::Failed { error, .. } => {
455                assert_eq!(error.code(), "APPLY_FAILED");
456            }
457            other => panic!("Expected Failed, got: {other:?}"),
458        }
459    }
460}