Skip to main content

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}