Skip to main content

sandogasa_cli/
lib.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3//! Shared CLI utilities for sandogasa tools.
4
5pub mod date;
6
7use std::process::{Command, Stdio};
8
9use url::{Host, Url};
10
11/// Environment variable that, when set to a non-empty value,
12/// disables [`ensure_secure_url`]'s plaintext-credential guard.
13/// Intended for local testing against `http://` mock servers or a
14/// trusted internal proxy — never for production credentials.
15pub const ALLOW_INSECURE_URL_ENV: &str = "SANDOGASA_ALLOW_INSECURE_URL";
16
17/// Refuse to hand credentials to a base URL that would transmit
18/// them in cleartext.
19///
20/// Returns `Ok(())` when the URL is `https`, when its host is a
21/// loopback address (`localhost`, `127.0.0.0/8`, `::1` — so mock
22/// servers and local development keep working), or when
23/// [`ALLOW_INSECURE_URL_ENV`] is set to a non-empty value.
24/// Otherwise returns an error naming the URL and the override, so
25/// an API token is never put on the wire over plain `http`.
26///
27/// Call this wherever a client is built with a token, before any
28/// request is made.
29pub fn ensure_secure_url(base_url: &str) -> Result<(), String> {
30    let allow_insecure = std::env::var_os(ALLOW_INSECURE_URL_ENV).is_some_and(|v| !v.is_empty());
31    check_secure_url(base_url, allow_insecure)
32}
33
34/// Pure core of [`ensure_secure_url`], with the env override passed
35/// in so it can be unit-tested without mutating process state.
36fn check_secure_url(base_url: &str, allow_insecure: bool) -> Result<(), String> {
37    let parsed = Url::parse(base_url).map_err(|e| format!("invalid URL '{base_url}': {e}"))?;
38    if parsed.scheme() == "https" || host_is_loopback(&parsed) {
39        return Ok(());
40    }
41    if allow_insecure {
42        return Ok(());
43    }
44    Err(format!(
45        "refusing to send credentials to '{base_url}' over plaintext \
46         {}: use an https URL, or set {ALLOW_INSECURE_URL_ENV}=1 to \
47         override (e.g. for local testing against a mock server).",
48        parsed.scheme()
49    ))
50}
51
52/// Whether a URL's host is a loopback address.
53fn host_is_loopback(u: &Url) -> bool {
54    match u.host() {
55        Some(Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
56        Some(Host::Ipv4(ip)) => ip.is_loopback(),
57        Some(Host::Ipv6(ip)) => ip.is_loopback(),
58        None => false,
59    }
60}
61
62/// Check that an external tool is available in `$PATH`.
63///
64/// Runs `<name> <version_arg>` and returns `Ok(())` if it exits
65/// successfully, or an error message with the install hint.
66/// Most tools use `--version`; see [`require_tool`] for a
67/// convenience wrapper.
68///
69/// # Example
70///
71/// ```no_run
72/// // koji uses `version` subcommand instead of `--version`
73/// sandogasa_cli::require_tool_with_arg("koji", "version", "sudo dnf install koji").unwrap();
74/// ```
75pub fn require_tool_with_arg(
76    name: &str,
77    version_arg: &str,
78    install_hint: &str,
79) -> Result<(), String> {
80    match Command::new(name)
81        .arg(version_arg)
82        .stdout(Stdio::null())
83        .stderr(Stdio::null())
84        .status()
85    {
86        Ok(s) if s.success() => Ok(()),
87        Ok(s) => Err(format!(
88            "{name} exited with {s}; is it installed correctly? \
89             Install it with: {install_hint}"
90        )),
91        Err(_) => Err(format!("{name} not found. Install it with: {install_hint}")),
92    }
93}
94
95/// Check that an external tool is available in `$PATH`.
96///
97/// Runs `<name> --version` and returns `Ok(())` if it exits
98/// successfully, or an error message with the install hint.
99///
100/// For tools that use a different version probe (e.g. `koji version`
101/// instead of `koji --version`), use [`require_tool_with_arg`].
102///
103/// # Example
104///
105/// ```no_run
106/// sandogasa_cli::require_tool("fedrq", "sudo dnf install fedrq").unwrap();
107/// ```
108pub fn require_tool(name: &str, install_hint: &str) -> Result<(), String> {
109    require_tool_with_arg(name, "--version", install_hint)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn require_missing_tool() {
118        let result = require_tool("nonexistent_tool_xyz_123", "magic install");
119        assert!(result.is_err());
120        let msg = result.unwrap_err();
121        assert!(msg.contains("nonexistent_tool_xyz_123"));
122        assert!(msg.contains("magic install"));
123    }
124
125    #[test]
126    fn require_available_tool() {
127        // `true` is a standard Unix utility that always succeeds.
128        // It doesn't support --version but some impls exit 0 anyway.
129        // Use `sh` which reliably exists and handles --version.
130        let result = require_tool("sh", "should already be installed");
131        // sh --version may or may not succeed depending on implementation,
132        // so just verify it doesn't panic.
133        let _ = result;
134    }
135
136    #[test]
137    fn secure_url_allows_https() {
138        assert!(check_secure_url("https://bugzilla.redhat.com", false).is_ok());
139        assert!(check_secure_url("https://gitlab.com/api/v4", false).is_ok());
140    }
141
142    #[test]
143    fn secure_url_allows_loopback_over_http() {
144        // Mock servers / local dev: loopback is fine over http.
145        assert!(check_secure_url("http://127.0.0.1:8080", false).is_ok());
146        assert!(check_secure_url("http://localhost:3000/api", false).is_ok());
147        assert!(check_secure_url("http://[::1]:9999", false).is_ok());
148    }
149
150    #[test]
151    fn secure_url_rejects_plaintext_remote() {
152        let err = check_secure_url("http://gitlab.example.com", false).unwrap_err();
153        assert!(err.contains("gitlab.example.com"));
154        assert!(err.contains(ALLOW_INSECURE_URL_ENV));
155    }
156
157    #[test]
158    fn secure_url_override_allows_plaintext_remote() {
159        // With the override "set", plaintext to a remote host is allowed.
160        assert!(check_secure_url("http://gitlab.example.com", true).is_ok());
161    }
162
163    #[test]
164    fn secure_url_rejects_invalid() {
165        assert!(check_secure_url("not a url", false).is_err());
166    }
167}