arch_toolkit/install/shell.rs
1//! Shell safety utilities: quoting and package name validation.
2
3use crate::error::{ArchToolkitError, Result};
4
5/// What: Safely single-quote an arbitrary string for POSIX shells.
6///
7/// Inputs:
8/// - `s`: Text to quote.
9///
10/// Output:
11/// - New string wrapped in single quotes, escaping embedded quotes via the
12/// `'"'"'` sequence.
13///
14/// Details:
15/// - Returns `''` for empty input so the shell treats it as an empty argument.
16/// - Ported from Pacsea's `install/utils.rs`.
17///
18/// # Example
19///
20/// ```
21/// use arch_toolkit::install::shell_single_quote;
22///
23/// assert_eq!(shell_single_quote("plain"), "'plain'");
24/// assert_eq!(shell_single_quote("it's"), r#"'it'"'"'s'"#);
25/// assert_eq!(shell_single_quote(""), "''");
26/// ```
27#[must_use]
28pub fn shell_single_quote(s: &str) -> String {
29 if s.is_empty() {
30 return "''".to_string();
31 }
32 let mut out = String::with_capacity(s.len() + 2);
33 out.push('\'');
34 for ch in s.chars() {
35 if ch == '\'' {
36 out.push_str("'\"'\"'");
37 } else {
38 out.push(ch);
39 }
40 }
41 out.push('\'');
42 out
43}
44
45/// What: Check whether a package name matches the strict allowlist used for install commands.
46///
47/// Inputs:
48/// - `name`: Candidate package name to validate.
49///
50/// Output:
51/// - `true` when `name` starts with a lowercase ASCII letter or digit and every
52/// remaining byte is one of `a-z`, `0-9`, `@`, `.`, `_`, `+`, `-`.
53///
54/// Details:
55/// - Defense-in-depth gate before command construction, matching Arch's
56/// package naming rules (lowercase only).
57/// - The first byte may not be `-` or `.`, so a name can never be parsed as an
58/// option (`--help`, `-S`) or a hidden path, even before the `--` operand
59/// terminator that all builders emit.
60/// - Internal `@ . _ + -` remain valid, preserving `lib32-*`, split packages,
61/// versioned names such as `python3.12`, and `+` names.
62/// - Ported from Pacsea's `install/utils.rs`.
63///
64/// # Example
65///
66/// ```
67/// use arch_toolkit::install::is_safe_package_name;
68///
69/// assert!(is_safe_package_name("ripgrep"));
70/// assert!(is_safe_package_name("libc++"));
71/// assert!(is_safe_package_name("lib32-glibc"));
72/// assert!(!is_safe_package_name("bad;rm -rf"));
73/// assert!(!is_safe_package_name("Upper"));
74/// assert!(!is_safe_package_name("--help"));
75/// assert!(!is_safe_package_name(".hidden"));
76/// assert!(!is_safe_package_name(""));
77/// ```
78#[must_use]
79pub fn is_safe_package_name(name: &str) -> bool {
80 let mut bytes = name.bytes();
81 let Some(first) = bytes.next() else {
82 return false;
83 };
84 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
85 return false;
86 }
87 bytes.all(|byte| {
88 byte.is_ascii_lowercase()
89 || byte.is_ascii_digit()
90 || matches!(byte, b'@' | b'.' | b'_' | b'+' | b'-')
91 })
92}
93
94/// What: Validate a list of package names against the strict install-command allowlist.
95///
96/// Inputs:
97/// - `names`: Package names that will be placed into commands.
98/// - `context`: Human-readable operation context for actionable error messages.
99///
100/// Output:
101/// - `Ok(())` when all names are valid.
102/// - `Err(ArchToolkitError::InvalidPackageName)` naming the first invalid package.
103///
104/// Details:
105/// - Centralises validation so all install builders apply the same safety policy.
106/// - The reported pattern states the leading-byte restriction that prevents
107/// option confusion.
108/// - Ported from Pacsea's `install/utils.rs`, adapted to `ArchToolkitError`.
109///
110/// # Errors
111///
112/// Returns `ArchToolkitError::InvalidPackageName` for the first name that fails
113/// `is_safe_package_name()`.
114pub fn validate_package_names<S: AsRef<str>>(names: &[S], context: &str) -> Result<()> {
115 if let Some(invalid) = names
116 .iter()
117 .find(|name| !is_safe_package_name(name.as_ref()))
118 {
119 return Err(ArchToolkitError::InvalidPackageName {
120 name: invalid.as_ref().to_string(),
121 reason: format!(
122 "invalid name for {context}; allowed pattern: ^[a-z0-9][a-z0-9@._+-]*$"
123 ),
124 });
125 }
126 Ok(())
127}
128
129/// What: Determine whether a command is available on the Unix `PATH`.
130///
131/// Inputs:
132/// - `cmd`: Program basename or path containing a path separator.
133///
134/// Output:
135/// - `true` when an executable file is found with the executable bit set.
136///
137/// Details:
138/// - Honours Unix permission bits so a non-executable file on `PATH` is not
139/// treated as a tool.
140/// - Ported from Pacsea's `install/utils.rs`.
141#[must_use]
142pub fn command_on_path(cmd: &str) -> bool {
143 resolve_command_on_path(cmd).is_some()
144}
145
146/// What: Resolve an executable on `PATH` or by explicit path.
147///
148/// Inputs:
149/// - `cmd`: Program basename or path containing a path separator.
150///
151/// Output:
152/// - `Some(path)` for the first executable match; otherwise `None`.
153///
154/// Details:
155/// - When `cmd` contains a path separator, only that path is checked.
156/// - Otherwise, each `PATH` directory is searched in order.
157#[must_use]
158pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
159 use std::path::Path;
160
161 if cmd.contains(std::path::MAIN_SEPARATOR) {
162 let p = Path::new(cmd);
163 return path_is_executable(p).then(|| p.to_path_buf());
164 }
165
166 let paths = std::env::var_os("PATH")?;
167 for dir in std::env::split_paths(&paths) {
168 let candidate = dir.join(cmd);
169 if path_is_executable(&candidate) {
170 return Some(candidate);
171 }
172 }
173 None
174}
175
176/// What: Check whether a path exists and is executable.
177///
178/// Inputs:
179/// - `path`: Filesystem path to inspect.
180///
181/// Output:
182/// - `true` when the path is a file with an executable permission bit (Unix).
183///
184/// Details:
185/// - On non-Unix platforms, only file existence is checked.
186fn path_is_executable(path: &std::path::Path) -> bool {
187 let Ok(metadata) = std::fs::metadata(path) else {
188 return false;
189 };
190 if !metadata.is_file() {
191 return false;
192 }
193 #[cfg(unix)]
194 {
195 use std::os::unix::fs::PermissionsExt;
196 metadata.permissions().mode() & 0o111 != 0
197 }
198 #[cfg(not(unix))]
199 {
200 true
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 /// What: Verify single-quoting handles plain, empty, and quote-embedded strings.
210 ///
211 /// Inputs:
212 /// - Assorted strings including embedded single quotes.
213 ///
214 /// Output:
215 /// - Correctly escaped shell-safe strings.
216 ///
217 /// Details:
218 /// - The `'"'"'` sequence must appear for embedded quotes.
219 fn quoting() {
220 assert_eq!(shell_single_quote("abc"), "'abc'");
221 assert_eq!(shell_single_quote(""), "''");
222 assert_eq!(shell_single_quote("a'b"), r#"'a'"'"'b'"#);
223 }
224
225 #[test]
226 /// What: Verify the package name allowlist accepts valid and rejects unsafe names.
227 ///
228 /// Inputs:
229 /// - Valid Arch names and injection attempts.
230 ///
231 /// Output:
232 /// - `true` only for names matching `^[a-z0-9@._+-]+$`.
233 ///
234 /// Details:
235 /// - Uppercase, whitespace, shell metacharacters, and leading `-`/`.` must
236 /// be rejected; internal punctuation must stay valid.
237 fn safe_names() {
238 for good in [
239 "ripgrep",
240 "gcc12+libs",
241 "lib32-glibc",
242 "python3.12",
243 "a@b_c",
244 "0ad",
245 ] {
246 assert!(is_safe_package_name(good), "{good} should be valid");
247 }
248 for bad in [
249 "",
250 "Upper",
251 "a b",
252 "x;y",
253 "$(rm)",
254 "a`b`",
255 "name'quote",
256 "-S",
257 "--help",
258 "-",
259 ".hidden",
260 ".",
261 "@scoped",
262 "_leading",
263 "+plus",
264 ] {
265 assert!(!is_safe_package_name(bad), "{bad} should be invalid");
266 }
267 }
268
269 #[test]
270 /// What: Verify batch validation reports the first invalid name with context.
271 ///
272 /// Inputs:
273 /// - Name list containing one injection attempt.
274 ///
275 /// Output:
276 /// - `InvalidPackageName` error naming the offending package and context.
277 ///
278 /// Details:
279 /// - Valid lists must pass unchanged.
280 fn validation() {
281 assert!(validate_package_names(&["vim", "git"], "test").is_ok());
282 let leading = validate_package_names(&["vim", "--help"], "test install");
283 assert!(leading.is_err(), "leading option names must be rejected");
284 let err = validate_package_names(&["vim", "bad;name"], "test install")
285 .expect_err("should reject");
286 match err {
287 crate::error::ArchToolkitError::InvalidPackageName { name, reason } => {
288 assert_eq!(name, "bad;name");
289 assert!(reason.contains("test install"));
290 assert!(reason.contains("^[a-z0-9][a-z0-9@._+-]*$"));
291 }
292 other => panic!("unexpected error: {other:?}"),
293 }
294 }
295
296 #[test]
297 /// What: Verify PATH lookup finds a universally present binary and rejects nonsense.
298 ///
299 /// Inputs:
300 /// - `sh` (present on all POSIX systems) and a random missing name.
301 ///
302 /// Output:
303 /// - `true` for `sh`, `false` for the missing binary.
304 ///
305 /// Details:
306 /// - Keeps the check portable across CI environments.
307 fn path_lookup() {
308 assert!(command_on_path("sh"));
309 assert!(!command_on_path("definitely-not-a-real-binary-xyz"));
310 }
311}