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
8pub struct DelegateApplyOptions {
10 pub mode: Option<DelegateMode>,
12 pub timeout_ms: Option<u64>,
14 pub on_progress: Option<Box<dyn Fn(ApplyProgress) + Send + Sync>>,
16 pub cmd: Option<Arc<dyn CommandRunner>>,
18}
19
20pub struct DelegateApplyResult {
22 pub kind: DelegateResultKind,
23}
24
25pub enum DelegateResultKind {
27 PrintOnly { message: String },
29 Executed {
31 exit_code: Option<i32>,
32 stdout: String,
33 stderr: String,
34 },
35}
36
37const COMMAND_SAFELIST: &[&str] = &[
39 "npm", "npx", "brew", "apt", "apt-get", "yum", "dnf", "choco", "winget", "scoop",
40];
41
42pub 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 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 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
182pub fn validate_command(program: &str) -> Result<(), UpdateKitError> {
184 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 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 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}