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;
6pub mod defaults;
7
8pub use defaults::parse_with_defaults;
9
10use std::process::{Command, Stdio};
11
12use url::{Host, Url};
13
14/// Standard process-wide initialization for sandogasa tools.
15///
16/// Call this once as the first statement of `main()` in every
17/// binary. It is the single place for cross-cutting startup work:
18/// anything added to this function is automatically picked up by
19/// every tool that calls it, so prefer extending `init` over
20/// scattering setup across mains.
21///
22/// Today it registers the rustls crypto provider that reqwest's
23/// TLS support needs (see [`install_crypto_provider`]). Idempotent
24/// and cheap, so calling it from a tool that does no networking is
25/// harmless.
26pub fn init() {
27    install_crypto_provider();
28}
29
30/// Install the ring-based rustls [`CryptoProvider`] as the process
31/// default.
32///
33/// We build reqwest with the `rustls-no-provider` feature to keep
34/// `aws-lc-rs` — reqwest 0.13's default provider, which is not
35/// packaged in Fedora — out of the dependency tree. That leaves
36/// rustls with no compiled-in default provider, so one must be
37/// registered at runtime before the first HTTPS request or reqwest
38/// panics with "No provider set". `ring` is statically linked into
39/// the binary (a build-time dependency only); this just points
40/// rustls at it.
41///
42/// Idempotent: the underlying `install_default` only takes effect
43/// on the first call and reports an error on subsequent ones, which
44/// we ignore so repeated calls (e.g. across tests) are harmless.
45///
46/// [`CryptoProvider`]: rustls::crypto::CryptoProvider
47pub fn install_crypto_provider() {
48    let _ = rustls::crypto::ring::default_provider().install_default();
49}
50
51/// Environment variable that, when set to a non-empty value,
52/// disables [`ensure_secure_url`]'s plaintext-credential guard.
53/// Intended for local testing against `http://` mock servers or a
54/// trusted internal proxy — never for production credentials.
55pub const ALLOW_INSECURE_URL_ENV: &str = "SANDOGASA_ALLOW_INSECURE_URL";
56
57/// Refuse to hand credentials to a base URL that would transmit
58/// them in cleartext.
59///
60/// Returns `Ok(())` when the URL is `https`, when its host is a
61/// loopback address (`localhost`, `127.0.0.0/8`, `::1` — so mock
62/// servers and local development keep working), or when
63/// [`ALLOW_INSECURE_URL_ENV`] is set to a non-empty value.
64/// Otherwise returns an error naming the URL and the override, so
65/// an API token is never put on the wire over plain `http`.
66///
67/// Call this wherever a client is built with a token, before any
68/// request is made.
69pub fn ensure_secure_url(base_url: &str) -> Result<(), String> {
70    let allow_insecure = std::env::var_os(ALLOW_INSECURE_URL_ENV).is_some_and(|v| !v.is_empty());
71    check_secure_url(base_url, allow_insecure)
72}
73
74/// Pure core of [`ensure_secure_url`], with the env override passed
75/// in so it can be unit-tested without mutating process state.
76fn check_secure_url(base_url: &str, allow_insecure: bool) -> Result<(), String> {
77    let parsed = Url::parse(base_url).map_err(|e| format!("invalid URL '{base_url}': {e}"))?;
78    if parsed.scheme() == "https" || host_is_loopback(&parsed) {
79        return Ok(());
80    }
81    if allow_insecure {
82        return Ok(());
83    }
84    Err(format!(
85        "refusing to send credentials to '{base_url}' over plaintext \
86         {}: use an https URL, or set {ALLOW_INSECURE_URL_ENV}=1 to \
87         override (e.g. for local testing against a mock server).",
88        parsed.scheme()
89    ))
90}
91
92/// Whether a URL's host is a loopback address.
93fn host_is_loopback(u: &Url) -> bool {
94    match u.host() {
95        Some(Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
96        Some(Host::Ipv4(ip)) => ip.is_loopback(),
97        Some(Host::Ipv6(ip)) => ip.is_loopback(),
98        None => false,
99    }
100}
101
102/// Whether an executable named `name` is on `$PATH` (a lightweight
103/// check that does **not** run the tool).
104pub fn tool_exists(name: &str) -> bool {
105    std::env::var_os("PATH")
106        .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file()))
107        .unwrap_or(false)
108}
109
110/// Whether `exe` is available, per its `probe`: `Some(arg)` runs
111/// `exe arg` and requires a zero exit (confirms it executes);
112/// `None` checks only `$PATH` existence.
113fn tool_available(exe: &str, probe: Option<&str>) -> bool {
114    match probe {
115        Some(arg) => Command::new(exe)
116            .arg(arg)
117            .stdout(Stdio::null())
118            .stderr(Stdio::null())
119            .status()
120            .is_ok_and(|s| s.success()),
121        None => tool_exists(exe),
122    }
123}
124
125/// Check that a batch of external tools is available, returning a
126/// single error that lists every missing one with its install hint.
127///
128/// Each entry is `(executable, install_hint, probe)`:
129/// - `probe = Some(arg)` *runs* `<executable> <arg>` (e.g.
130///   `Some("--version")`, or `Some("version")` for `koji`, or
131///   `Some("--help")` for `pbuilder-dist`) and requires a zero exit,
132///   confirming the tool actually executes.
133/// - `probe = None` checks only `$PATH` existence, for tools with no
134///   usable version/help flag.
135///
136/// All entries are checked, so the error names every missing tool
137/// rather than failing on the first.
138///
139/// # Example
140///
141/// ```no_run
142/// sandogasa_cli::require_tools(&[
143///     ("git", "sudo apt install git", Some("--version")),
144///     ("pbuilder-dist", "sudo apt install ubuntu-dev-tools", Some("--help")),
145/// ])
146/// .unwrap();
147/// ```
148pub fn require_tools(tools: &[(&str, &str, Option<&str>)]) -> Result<(), String> {
149    let missing: Vec<String> = tools
150        .iter()
151        .filter(|(exe, _, probe)| !tool_available(exe, *probe))
152        .map(|(exe, hint, _)| format!("{exe} (install: {hint})"))
153        .collect();
154    if missing.is_empty() {
155        Ok(())
156    } else {
157        Err(format!("missing required tool(s): {}", missing.join(", ")))
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn tool_exists_detects_present_and_absent() {
167        assert!(tool_exists("sh"));
168        assert!(!tool_exists("nonexistent_tool_xyz_123"));
169    }
170
171    #[test]
172    fn require_tools_path_and_probe_modes() {
173        // PATH mode (probe None): present is OK, absent is missing.
174        assert!(require_tools(&[("sh", "present", None)]).is_ok());
175        assert!(require_tools(&[("nonexistent_zzz", "install zzz", None)]).is_err());
176
177        // Probe mode: `true` runs and exits 0; a missing executable
178        // fails the probe. The error lists every missing tool with its
179        // hint, and skips the present one.
180        assert!(require_tools(&[("true", "ok", Some("--version"))]).is_ok());
181        let err = require_tools(&[
182            ("true", "ok", Some("--version")),
183            ("nonexistent_aaa_111", "install aaa", Some("--version")),
184            ("nonexistent_bbb_222", "install bbb", None),
185        ])
186        .unwrap_err();
187        assert!(err.contains("nonexistent_aaa_111"));
188        assert!(err.contains("install aaa"));
189        assert!(err.contains("nonexistent_bbb_222"));
190        assert!(err.contains("install bbb"));
191        assert!(!err.contains("true ("));
192    }
193
194    #[test]
195    fn secure_url_allows_https() {
196        assert!(check_secure_url("https://bugzilla.redhat.com", false).is_ok());
197        assert!(check_secure_url("https://gitlab.com/api/v4", false).is_ok());
198    }
199
200    #[test]
201    fn secure_url_allows_loopback_over_http() {
202        // Mock servers / local dev: loopback is fine over http.
203        assert!(check_secure_url("http://127.0.0.1:8080", false).is_ok());
204        assert!(check_secure_url("http://localhost:3000/api", false).is_ok());
205        assert!(check_secure_url("http://[::1]:9999", false).is_ok());
206    }
207
208    #[test]
209    fn secure_url_rejects_plaintext_remote() {
210        let err = check_secure_url("http://gitlab.example.com", false).unwrap_err();
211        assert!(err.contains("gitlab.example.com"));
212        assert!(err.contains(ALLOW_INSECURE_URL_ENV));
213    }
214
215    #[test]
216    fn secure_url_override_allows_plaintext_remote() {
217        // With the override "set", plaintext to a remote host is allowed.
218        assert!(check_secure_url("http://gitlab.example.com", true).is_ok());
219    }
220
221    #[test]
222    fn secure_url_rejects_invalid() {
223        assert!(check_secure_url("not a url", false).is_err());
224    }
225}