arch_toolkit/install/command.rs
1//! Command builders for package installation, removal, and updates.
2//!
3//! All builders return [`CommandSpec`] values — arch-toolkit never executes
4//! commands. Dry runs are the caller's choice: display `spec.to_shell_string()`
5//! instead of spawning.
6
7use crate::error::Result;
8use crate::types::install::{AurHelper, CascadeMode, CommandSpec, InstallOptions, PrivilegeTool};
9
10use super::shell::validate_package_names;
11
12/// Message printed by shell-fallback bodies when no AUR helper is installed.
13///
14/// Matches Pacsea's terminal-install error text so migrating callers keep
15/// byte-identical output.
16pub const NO_AUR_HELPER_MESSAGE: &str = "No AUR helper (paru/yay) found.";
17
18/// Exit status used by shell-fallback bodies when no AUR helper is installed.
19///
20/// `127` is the POSIX convention for "command not found", so callers can
21/// distinguish a missing helper from a failed package operation.
22const NO_AUR_HELPER_STATUS: u8 = 127;
23
24/// POSIX option terminator placed between flags and package operands.
25///
26/// Prevents pacman, paru, and yay from parsing any operand as an option.
27const OPERAND_TERMINATOR: &str = "--";
28
29/// What: Build a pacman install command for official repository packages.
30///
31/// Inputs:
32/// - `names`: Package names to install (validated against the safe-name allowlist).
33/// - `options`: Flag options (`needed`, `noconfirm`; `aur_only` is ignored).
34///
35/// Output:
36/// - `Ok(CommandSpec)` like `pacman -S --needed --noconfirm -- <names...>`.
37///
38/// Details:
39/// - Does NOT prefix a privilege tool; use [`with_privilege`] for that.
40/// - Omit `--needed` (set `options.needed = false`) for explicit reinstalls,
41/// mirroring Pacsea's reinstall path.
42/// - A `--` operand terminator separates flags from package names so pacman can
43/// never reinterpret an operand as an option (defense in depth on top of
44/// name validation).
45///
46/// # Errors
47///
48/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
49/// or `ArchToolkitError::EmptyInput` when `names` is empty.
50///
51/// # Example
52///
53/// ```
54/// use arch_toolkit::install::build_pacman_install;
55/// use arch_toolkit::types::install::InstallOptions;
56///
57/// let spec = build_pacman_install(&["ripgrep", "fd"], &InstallOptions::default())?;
58/// assert_eq!(spec.to_shell_string(), "pacman -S --needed --noconfirm -- ripgrep fd");
59/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
60/// ```
61pub fn build_pacman_install<S: AsRef<str>>(
62 names: &[S],
63 options: &InstallOptions,
64) -> Result<CommandSpec> {
65 validate_non_empty(names, "pacman install")?;
66 validate_package_names(names, "pacman install")?;
67 let mut args = vec!["-S".to_string()];
68 if options.needed {
69 args.push("--needed".to_string());
70 }
71 if options.noconfirm {
72 args.push("--noconfirm".to_string());
73 }
74 push_operands(&mut args, names);
75 Ok(CommandSpec {
76 program: "pacman".to_string(),
77 args,
78 })
79}
80
81/// What: Build an AUR helper install command for AUR packages.
82///
83/// Inputs:
84/// - `helper`: The AUR helper to use (from caller config or [`super::detect_aur_helper`]).
85/// - `names`: AUR package names to install (validated).
86/// - `options`: Flag options (`needed`, `noconfirm`, `aur_only`).
87///
88/// Output:
89/// - `Ok(CommandSpec)` like `paru -S --aur --needed --noconfirm -- <names...>`.
90///
91/// Details:
92/// - `--aur` (when `options.aur_only`) ensures helpers do not prefer a sync
93/// database (e.g., Chaotic-AUR) when the same name exists on the AUR —
94/// matching Pacsea's `aur_install_helper_flags`.
95/// - A `--` operand terminator separates flags from package names; paru and yay
96/// forward it to pacman-style operand parsing.
97/// - AUR helpers must NOT run under sudo; they invoke sudo themselves for the
98/// pacman step. Do not wrap the result in [`with_privilege`].
99///
100/// # Errors
101///
102/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
103/// or `ArchToolkitError::EmptyInput` when `names` is empty.
104///
105/// # Example
106///
107/// ```
108/// use arch_toolkit::install::build_aur_install;
109/// use arch_toolkit::types::install::{AurHelper, InstallOptions};
110///
111/// let spec = build_aur_install(AurHelper::Paru, &["yay-bin"], &InstallOptions::default())?;
112/// assert_eq!(spec.to_shell_string(), "paru -S --aur --needed --noconfirm -- yay-bin");
113/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
114/// ```
115pub fn build_aur_install<S: AsRef<str>>(
116 helper: AurHelper,
117 names: &[S],
118 options: &InstallOptions,
119) -> Result<CommandSpec> {
120 validate_non_empty(names, "AUR install")?;
121 validate_package_names(names, "AUR install")?;
122 let mut args = vec!["-S".to_string()];
123 if options.aur_only {
124 args.push("--aur".to_string());
125 }
126 if options.needed {
127 args.push("--needed".to_string());
128 }
129 if options.noconfirm {
130 args.push("--noconfirm".to_string());
131 }
132 push_operands(&mut args, names);
133 Ok(CommandSpec {
134 program: helper.binary_name().to_string(),
135 args,
136 })
137}
138
139/// What: Build a pacman remove command with the requested cascade level.
140///
141/// Inputs:
142/// - `names`: Package names to remove (validated).
143/// - `cascade`: Cascade level (`-R`, `-Rs`, or `-Rns`).
144/// - `noconfirm`: Pass `--noconfirm` for non-interactive removal.
145///
146/// Output:
147/// - `Ok(CommandSpec)` like `pacman -Rns --noconfirm -- <names...>`.
148///
149/// Details:
150/// - Does NOT prefix a privilege tool; use [`with_privilege`] for that.
151/// - Cascade semantics ported from Pacsea's `CascadeMode`.
152/// - A `--` operand terminator separates flags from package names.
153///
154/// # Errors
155///
156/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
157/// or `ArchToolkitError::EmptyInput` when `names` is empty.
158///
159/// # Example
160///
161/// ```
162/// use arch_toolkit::install::build_remove_command;
163/// use arch_toolkit::types::install::CascadeMode;
164///
165/// let spec = build_remove_command(&["ripgrep"], CascadeMode::CascadeWithConfigs, true)?;
166/// assert_eq!(spec.to_shell_string(), "pacman -Rns --noconfirm -- ripgrep");
167/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
168/// ```
169pub fn build_remove_command<S: AsRef<str>>(
170 names: &[S],
171 cascade: CascadeMode,
172 noconfirm: bool,
173) -> Result<CommandSpec> {
174 validate_non_empty(names, "remove")?;
175 validate_package_names(names, "remove")?;
176 let mut args = vec![cascade.flag().to_string()];
177 if noconfirm {
178 args.push("--noconfirm".to_string());
179 }
180 push_operands(&mut args, names);
181 Ok(CommandSpec {
182 program: "pacman".to_string(),
183 args,
184 })
185}
186
187/// What: Append the `--` operand terminator followed by validated package names.
188///
189/// Inputs:
190/// - `args`: Argument vector already containing every flag for the command.
191/// - `names`: Validated package names to place after the terminator.
192///
193/// Output:
194/// - Side effect: `args` gains `--` and then one entry per package name.
195///
196/// Details:
197/// - Called only by builders that take package operands; operand-free commands
198/// such as `-Syu`, `-Syyu`, and `-Sua` must never gain a terminator.
199/// - Callers must validate names first; this helper performs no validation.
200fn push_operands<S: AsRef<str>>(args: &mut Vec<String>, names: &[S]) {
201 args.push(OPERAND_TERMINATOR.to_string());
202 args.extend(names.iter().map(|n| n.as_ref().to_string()));
203}
204
205/// What: Build a full-system update command.
206///
207/// Inputs:
208/// - `helper`: When `Some`, use the AUR helper (`paru -Syu`) which updates both
209/// official and AUR packages. When `None`, use plain `pacman -Syu`.
210/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
211///
212/// Output:
213/// - `CommandSpec` like `paru -Syu --noconfirm` or `pacman -Syu --noconfirm`.
214///
215/// Details:
216/// - The pacman variant requires privilege wrapping ([`with_privilege`]);
217/// helper variants must not be wrapped (helpers call sudo themselves).
218///
219/// # Example
220///
221/// ```
222/// use arch_toolkit::install::build_update_command;
223/// use arch_toolkit::types::install::AurHelper;
224///
225/// let pacman = build_update_command(None, true);
226/// assert_eq!(pacman.to_shell_string(), "pacman -Syu --noconfirm");
227///
228/// let helper = build_update_command(Some(AurHelper::Yay), false);
229/// assert_eq!(helper.to_shell_string(), "yay -Syu");
230/// ```
231#[must_use]
232pub fn build_update_command(helper: Option<AurHelper>, noconfirm: bool) -> CommandSpec {
233 let program = helper.map_or("pacman", AurHelper::binary_name).to_string();
234 let mut args = vec!["-Syu".to_string()];
235 if noconfirm {
236 args.push("--noconfirm".to_string());
237 }
238 CommandSpec { program, args }
239}
240
241/// What: Build a full-system update command that force-refreshes sync databases.
242///
243/// Inputs:
244/// - `helper`: When `Some`, use the AUR helper; when `None`, plain pacman.
245/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
246///
247/// Output:
248/// - `CommandSpec` like `pacman -Syyu --noconfirm`.
249///
250/// Details:
251/// - `-Syyu` re-downloads all sync databases even when they appear up to date;
252/// use after mirror changes (mirrors Pacsea's force-sync update option).
253/// - Same privilege rules as [`build_update_command`].
254///
255/// # Example
256///
257/// ```
258/// use arch_toolkit::install::build_force_sync_update_command;
259///
260/// let spec = build_force_sync_update_command(None, true);
261/// assert_eq!(spec.to_shell_string(), "pacman -Syyu --noconfirm");
262/// ```
263#[must_use]
264pub fn build_force_sync_update_command(helper: Option<AurHelper>, noconfirm: bool) -> CommandSpec {
265 let program = helper.map_or("pacman", AurHelper::binary_name).to_string();
266 let mut args = vec!["-Syyu".to_string()];
267 if noconfirm {
268 args.push("--noconfirm".to_string());
269 }
270 CommandSpec { program, args }
271}
272
273/// What: Build an AUR-only update command (`-Sua`).
274///
275/// Inputs:
276/// - `helper`: The AUR helper to run the update with.
277/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
278///
279/// Output:
280/// - `CommandSpec` like `paru -Sua --noconfirm`.
281///
282/// Details:
283/// - Updates AUR packages only, leaving official packages to a separate
284/// `pacman -Syu` step — mirroring Pacsea's split system-update flow where
285/// the AUR step runs conditionally after the pacman step succeeds.
286/// - Must NOT be wrapped in [`with_privilege`]; helpers escalate internally.
287///
288/// # Example
289///
290/// ```
291/// use arch_toolkit::install::build_aur_update_command;
292/// use arch_toolkit::types::install::AurHelper;
293///
294/// let spec = build_aur_update_command(AurHelper::Paru, true);
295/// assert_eq!(spec.to_shell_string(), "paru -Sua --noconfirm");
296/// ```
297#[must_use]
298pub fn build_aur_update_command(helper: AurHelper, noconfirm: bool) -> CommandSpec {
299 let mut args = vec!["-Sua".to_string()];
300 if noconfirm {
301 args.push("--noconfirm".to_string());
302 }
303 CommandSpec {
304 program: helper.binary_name().to_string(),
305 args,
306 }
307}
308
309/// What: Build a shell body that installs AUR packages with runtime helper fallback.
310///
311/// Inputs:
312/// - `names`: AUR package names to install (validated).
313/// - `options`: Flag options (`needed`, `noconfirm`, `aur_only`).
314///
315/// Output:
316/// - `Ok(String)` with a POSIX shell snippet that picks `paru`, then `yay`,
317/// at execution time, or prints [`NO_AUR_HELPER_MESSAGE`].
318///
319/// Details:
320/// - Unlike [`build_aur_install`], helper selection happens inside the spawned
321/// shell (the terminal's `PATH`), not in the calling process — matching
322/// Pacsea's `aur_install_body`. Prefer this when the command runs in an
323/// external terminal whose environment may differ from the caller's.
324/// - Names pass the same strict validation as all builders, so interpolating
325/// them into the shell string is safe without quoting. A `--` operand
326/// terminator is emitted before the names for defense in depth.
327/// - When neither helper exists the body writes [`NO_AUR_HELPER_MESSAGE`] to
328/// stderr and exits the subshell with status 127, so callers see a failure
329/// instead of a successful no-op.
330///
331/// # Errors
332///
333/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
334/// or `ArchToolkitError::EmptyInput` when `names` is empty.
335///
336/// # Example
337///
338/// ```
339/// use arch_toolkit::install::aur_install_shell_fallback;
340/// use arch_toolkit::types::install::InstallOptions;
341///
342/// let body = aur_install_shell_fallback(&["yay-bin"], &InstallOptions::default())?;
343/// assert!(body.contains("if command -v paru >/dev/null 2>&1; then paru"));
344/// assert!(body.contains("elif command -v yay >/dev/null 2>&1; then yay"));
345/// assert!(body.contains("--noconfirm -- yay-bin"));
346/// assert!(body.contains("exit 127"));
347/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
348/// ```
349pub fn aur_install_shell_fallback<S: AsRef<str>>(
350 names: &[S],
351 options: &InstallOptions,
352) -> Result<String> {
353 validate_non_empty(names, "AUR install")?;
354 validate_package_names(names, "AUR install")?;
355 let mut flags = String::from("-S");
356 if options.aur_only {
357 flags.push_str(" --aur");
358 }
359 if options.needed {
360 flags.push_str(" --needed");
361 }
362 if options.noconfirm {
363 flags.push_str(" --noconfirm");
364 }
365 let joined = names
366 .iter()
367 .map(std::convert::AsRef::as_ref)
368 .collect::<Vec<_>>()
369 .join(" ");
370 Ok(helper_fallback_body(&format!(
371 "{flags} {OPERAND_TERMINATOR} {joined}"
372 )))
373}
374
375/// What: Build a shell body that updates AUR packages with runtime helper fallback.
376///
377/// Inputs:
378/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
379///
380/// Output:
381/// - A POSIX shell snippet running `paru -Sua` / `yay -Sua`, or printing
382/// [`NO_AUR_HELPER_MESSAGE`] when neither helper exists.
383///
384/// Details:
385/// - Shell-time counterpart of [`build_aur_update_command`], for callers that
386/// spawn the update in an external terminal (Pacsea's system-update flow).
387/// - Takes no package operands, so no `--` terminator is emitted.
388/// - The no-helper branch writes to stderr and exits the subshell with 127.
389///
390/// # Example
391///
392/// ```
393/// use arch_toolkit::install::aur_update_shell_fallback;
394///
395/// let body = aur_update_shell_fallback(true);
396/// assert!(body.contains("paru -Sua --noconfirm"));
397/// ```
398#[must_use]
399pub fn aur_update_shell_fallback(noconfirm: bool) -> String {
400 let flags = if noconfirm {
401 "-Sua --noconfirm"
402 } else {
403 "-Sua"
404 };
405 helper_fallback_body(flags)
406}
407
408/// What: Wrap helper arguments in the paru → yay runtime-fallback shell body.
409///
410/// Inputs:
411/// - `tail`: Flags and package names appended to the chosen helper.
412///
413/// Output:
414/// - Parenthesized `if/elif/else` snippet matching Pacsea's `aur_install_body`,
415/// with a failing no-helper branch.
416///
417/// Details:
418/// - The `else` branch writes [`NO_AUR_HELPER_MESSAGE`] to stderr and runs
419/// `exit 127`. Because the body is wrapped in `( ... )`, only the fallback
420/// subshell terminates; the caller observes a non-zero status.
421fn helper_fallback_body(tail: &str) -> String {
422 format!(
423 "(if command -v paru >/dev/null 2>&1; then paru {tail}; \
424 elif command -v yay >/dev/null 2>&1; then yay {tail}; \
425 else echo '{NO_AUR_HELPER_MESSAGE}' >&2; exit {NO_AUR_HELPER_STATUS}; fi)"
426 )
427}
428
429/// What: Wrap a command with a privilege escalation tool prefix.
430///
431/// Inputs:
432/// - `tool`: The privilege tool (`sudo` or `doas`).
433/// - `spec`: The command to wrap.
434///
435/// Output:
436/// - New `CommandSpec` like `sudo pacman -S ...`.
437///
438/// Details:
439/// - Pure transformation: the original program becomes the first argument.
440/// - Do NOT wrap AUR helper commands; helpers escalate internally.
441/// - Password handling (stdin piping, askpass) is intentionally out of scope.
442///
443/// # Example
444///
445/// ```
446/// use arch_toolkit::install::{build_update_command, with_privilege};
447/// use arch_toolkit::types::install::PrivilegeTool;
448///
449/// let spec = with_privilege(PrivilegeTool::Sudo, build_update_command(None, true));
450/// assert_eq!(spec.to_shell_string(), "sudo pacman -Syu --noconfirm");
451/// ```
452#[must_use]
453pub fn with_privilege(tool: PrivilegeTool, spec: CommandSpec) -> CommandSpec {
454 let mut args = Vec::with_capacity(spec.args.len() + 1);
455 args.push(spec.program);
456 args.extend(spec.args);
457 CommandSpec {
458 program: tool.binary_name().to_string(),
459 args,
460 }
461}
462
463/// What: Reject empty name lists with a descriptive error.
464///
465/// Inputs:
466/// - `names`: The name list to check.
467/// - `context`: Operation context for the error message.
468///
469/// Output:
470/// - `Ok(())` when non-empty, `Err(ArchToolkitError::EmptyInput)` otherwise.
471///
472/// Details:
473/// - Prevents building commands like `pacman -S --noconfirm` with no targets.
474fn validate_non_empty<S: AsRef<str>>(names: &[S], context: &str) -> Result<()> {
475 if names.is_empty() {
476 return Err(crate::error::ArchToolkitError::EmptyInput {
477 field: "names".to_string(),
478 message: format!("at least one package name is required for {context}"),
479 });
480 }
481 Ok(())
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::error::ArchToolkitError;
488
489 #[test]
490 /// What: Verify pacman install flag combinations (fresh install vs reinstall).
491 ///
492 /// Inputs:
493 /// - Default options and reinstall options (needed=false).
494 ///
495 /// Output:
496 /// - `--needed` present only for fresh installs.
497 ///
498 /// Details:
499 /// - Mirrors Pacsea's `-S --needed --noconfirm` vs `-S --noconfirm` split.
500 fn pacman_install_flags() {
501 let fresh =
502 build_pacman_install(&["ripgrep"], &InstallOptions::default()).expect("build fresh");
503 assert_eq!(
504 fresh.to_shell_string(),
505 "pacman -S --needed --noconfirm -- ripgrep"
506 );
507
508 let reinstall_opts = InstallOptions {
509 needed: false,
510 ..Default::default()
511 };
512 let reinstall =
513 build_pacman_install(&["ripgrep"], &reinstall_opts).expect("build reinstall");
514 assert_eq!(
515 reinstall.to_shell_string(),
516 "pacman -S --noconfirm -- ripgrep"
517 );
518
519 let interactive = InstallOptions {
520 noconfirm: false,
521 ..Default::default()
522 };
523 let spec = build_pacman_install(&["a", "b"], &interactive).expect("build interactive");
524 assert_eq!(spec.to_shell_string(), "pacman -S --needed -- a b");
525 assert_eq!(spec.args, ["-S", "--needed", "--", "a", "b"]);
526 }
527
528 #[test]
529 /// What: Verify AUR install commands include `--aur` and helper preference.
530 ///
531 /// Inputs:
532 /// - Paru and yay helpers with default and reinstall options.
533 ///
534 /// Output:
535 /// - Flag sets matching Pacsea's `aur_install_helper_flags`.
536 ///
537 /// Details:
538 /// - Reinstall path omits `--needed`; `aur_only=false` omits `--aur`.
539 fn aur_install_flags() {
540 let spec = build_aur_install(AurHelper::Paru, &["yay-bin"], &InstallOptions::default())
541 .expect("build");
542 assert_eq!(
543 spec.to_shell_string(),
544 "paru -S --aur --needed --noconfirm -- yay-bin"
545 );
546
547 let reinstall = InstallOptions {
548 needed: false,
549 ..Default::default()
550 };
551 let spec2 = build_aur_install(AurHelper::Yay, &["yay-bin"], &reinstall).expect("build");
552 assert_eq!(
553 spec2.to_shell_string(),
554 "yay -S --aur --noconfirm -- yay-bin"
555 );
556
557 let no_aur_flag = InstallOptions {
558 aur_only: false,
559 ..Default::default()
560 };
561 let spec3 = build_aur_install(AurHelper::Paru, &["x"], &no_aur_flag).expect("build");
562 assert_eq!(spec3.to_shell_string(), "paru -S --needed --noconfirm -- x");
563 }
564
565 #[test]
566 /// What: Verify remove commands map cascade modes to pacman flags.
567 ///
568 /// Inputs:
569 /// - All three cascade modes.
570 ///
571 /// Output:
572 /// - `-R`, `-Rs`, `-Rns` respectively, with optional `--noconfirm`.
573 ///
574 /// Details:
575 /// - Matches Pacsea's `CascadeMode::flag()` semantics.
576 fn remove_cascade_modes() {
577 let basic = build_remove_command(&["pkg"], CascadeMode::Basic, false).expect("build basic");
578 assert_eq!(basic.to_shell_string(), "pacman -R -- pkg");
579
580 let cascade =
581 build_remove_command(&["pkg"], CascadeMode::Cascade, true).expect("build cascade");
582 assert_eq!(cascade.to_shell_string(), "pacman -Rs --noconfirm -- pkg");
583
584 let full = build_remove_command(&["a", "b"], CascadeMode::CascadeWithConfigs, true)
585 .expect("build full");
586 assert_eq!(full.to_shell_string(), "pacman -Rns --noconfirm -- a b");
587 assert_eq!(full.args, ["-Rns", "--noconfirm", "--", "a", "b"]);
588 }
589
590 #[test]
591 /// What: Verify update command builder for pacman and helper variants.
592 ///
593 /// Inputs:
594 /// - `None` (pacman) and `Some(helper)` variants.
595 ///
596 /// Output:
597 /// - `pacman -Syu` / `<helper> -Syu` with optional `--noconfirm`.
598 ///
599 /// Details:
600 /// - Helper variant updates both official and AUR packages.
601 /// - Operand-free update commands must not gain a `--` terminator.
602 fn update_commands() {
603 let pacman = build_update_command(None, true);
604 assert!(!pacman.args.iter().any(|arg| arg == "--"));
605 assert_eq!(pacman.to_shell_string(), "pacman -Syu --noconfirm");
606 assert_eq!(
607 build_update_command(Some(AurHelper::Paru), false).to_shell_string(),
608 "paru -Syu"
609 );
610 }
611
612 #[test]
613 /// What: Verify force-sync and AUR-only update builders.
614 ///
615 /// Inputs:
616 /// - Pacman and helper variants with and without `--noconfirm`.
617 ///
618 /// Output:
619 /// - `-Syyu` and `-Sua` flag sets matching Pacsea's system-update flow.
620 ///
621 /// Details:
622 /// - `-Sua` must target the helper only; `-Syyu` force-refreshes databases.
623 fn force_sync_and_aur_only_updates() {
624 assert_eq!(
625 build_force_sync_update_command(None, true).to_shell_string(),
626 "pacman -Syyu --noconfirm"
627 );
628 assert_eq!(
629 build_force_sync_update_command(Some(AurHelper::Yay), false).to_shell_string(),
630 "yay -Syyu"
631 );
632 assert_eq!(
633 build_aur_update_command(AurHelper::Paru, true).to_shell_string(),
634 "paru -Sua --noconfirm"
635 );
636 assert_eq!(
637 build_aur_update_command(AurHelper::Yay, false).to_shell_string(),
638 "yay -Sua"
639 );
640 }
641
642 #[test]
643 /// What: Verify runtime-fallback shell bodies match Pacsea's format.
644 ///
645 /// Inputs:
646 /// - Install and update fallback bodies with default options.
647 ///
648 /// Output:
649 /// - Parenthesized paru → yay `if/elif/else` with the exact error message,
650 /// an operand terminator, and a failing no-helper branch.
651 ///
652 /// Details:
653 /// - Helper selection happens at shell execution time, not plan time.
654 fn shell_fallback_bodies() {
655 let body = aur_install_shell_fallback(&["yay-bin"], &InstallOptions::default())
656 .expect("build body");
657 assert_eq!(
658 body,
659 "(if command -v paru >/dev/null 2>&1; \
660 then paru -S --aur --needed --noconfirm -- yay-bin; \
661 elif command -v yay >/dev/null 2>&1; \
662 then yay -S --aur --needed --noconfirm -- yay-bin; \
663 else echo 'No AUR helper (paru/yay) found.' >&2; exit 127; fi)"
664 );
665
666 let update = aur_update_shell_fallback(false);
667 assert!(update.contains("paru -Sua;"));
668 assert!(!update.contains("-Sua --"));
669 assert!(update.contains(NO_AUR_HELPER_MESSAGE));
670 assert!(update.contains("' >&2; exit 127; fi)"));
671
672 let inj = aur_install_shell_fallback(&["bad;rm -rf /"], &InstallOptions::default());
673 assert!(matches!(
674 inj,
675 Err(ArchToolkitError::InvalidPackageName { .. })
676 ));
677
678 let empty: [&str; 0] = [];
679 assert!(matches!(
680 aur_install_shell_fallback(&empty, &InstallOptions::default()),
681 Err(ArchToolkitError::EmptyInput { .. })
682 ));
683 }
684
685 #[test]
686 /// What: Verify privilege wrapping prepends the tool and shifts the program.
687 ///
688 /// Inputs:
689 /// - A pacman spec wrapped with sudo and doas.
690 ///
691 /// Output:
692 /// - `sudo pacman ...` / `doas pacman ...`.
693 ///
694 /// Details:
695 /// - The wrapped spec must preserve all original arguments in order.
696 fn privilege_wrapping() {
697 let spec = build_pacman_install(&["vim"], &InstallOptions::default()).expect("build");
698 let sudo = with_privilege(PrivilegeTool::Sudo, spec.clone());
699 assert_eq!(
700 sudo.to_shell_string(),
701 "sudo pacman -S --needed --noconfirm -- vim"
702 );
703 assert_eq!(
704 sudo.args,
705 ["pacman", "-S", "--needed", "--noconfirm", "--", "vim"]
706 );
707 let doas = with_privilege(PrivilegeTool::Doas, spec);
708 assert_eq!(
709 doas.to_shell_string(),
710 "doas pacman -S --needed --noconfirm -- vim"
711 );
712 }
713
714 #[test]
715 /// What: Verify builders reject invalid names and empty lists.
716 ///
717 /// Inputs:
718 /// - Injection attempt and empty slice.
719 ///
720 /// Output:
721 /// - `InvalidPackageName` and `EmptyInput` errors respectively.
722 ///
723 /// Details:
724 /// - Defense-in-depth: names are validated before any command is produced.
725 /// - Leading `-`/`.` names are rejected on every builder (U5).
726 fn validation_errors() {
727 for evil in ["--help", "-S", ".hidden"] {
728 assert!(
729 build_pacman_install(&[evil], &InstallOptions::default()).is_err(),
730 "pacman install should reject {evil}"
731 );
732 assert!(
733 build_aur_install(AurHelper::Paru, &[evil], &InstallOptions::default()).is_err(),
734 "AUR install should reject {evil}"
735 );
736 assert!(
737 build_remove_command(&[evil], CascadeMode::Basic, true).is_err(),
738 "remove should reject {evil}"
739 );
740 assert!(
741 aur_install_shell_fallback(&[evil], &InstallOptions::default()).is_err(),
742 "shell fallback should reject {evil}"
743 );
744 }
745
746 let inj = build_pacman_install(&["good", "bad;rm -rf /"], &InstallOptions::default());
747 assert!(matches!(
748 inj,
749 Err(ArchToolkitError::InvalidPackageName { .. })
750 ));
751
752 let empty: [&str; 0] = [];
753 let none = build_pacman_install(&empty, &InstallOptions::default());
754 assert!(matches!(none, Err(ArchToolkitError::EmptyInput { .. })));
755
756 let aur_inj = build_aur_install(AurHelper::Paru, &["$(evil)"], &InstallOptions::default());
757 assert!(matches!(
758 aur_inj,
759 Err(ArchToolkitError::InvalidPackageName { .. })
760 ));
761
762 let rm_inj = build_remove_command(&["a b"], CascadeMode::Basic, true);
763 assert!(matches!(
764 rm_inj,
765 Err(ArchToolkitError::InvalidPackageName { .. })
766 ));
767 }
768}