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/// Standard process-wide initialization for sandogasa tools.
12///
13/// Call this once as the first statement of `main()` in every
14/// binary. It is the single place for cross-cutting startup work:
15/// anything added to this function is automatically picked up by
16/// every tool that calls it, so prefer extending `init` over
17/// scattering setup across mains.
18///
19/// Today it registers the rustls crypto provider that reqwest's
20/// TLS support needs (see [`install_crypto_provider`]). Idempotent
21/// and cheap, so calling it from a tool that does no networking is
22/// harmless.
23pub fn init() {
24 install_crypto_provider();
25}
26
27/// Install the ring-based rustls [`CryptoProvider`] as the process
28/// default.
29///
30/// We build reqwest with the `rustls-no-provider` feature to keep
31/// `aws-lc-rs` — reqwest 0.13's default provider, which is not
32/// packaged in Fedora — out of the dependency tree. That leaves
33/// rustls with no compiled-in default provider, so one must be
34/// registered at runtime before the first HTTPS request or reqwest
35/// panics with "No provider set". `ring` is statically linked into
36/// the binary (a build-time dependency only); this just points
37/// rustls at it.
38///
39/// Idempotent: the underlying `install_default` only takes effect
40/// on the first call and reports an error on subsequent ones, which
41/// we ignore so repeated calls (e.g. across tests) are harmless.
42///
43/// [`CryptoProvider`]: rustls::crypto::CryptoProvider
44pub fn install_crypto_provider() {
45 let _ = rustls::crypto::ring::default_provider().install_default();
46}
47
48/// Environment variable that, when set to a non-empty value,
49/// disables [`ensure_secure_url`]'s plaintext-credential guard.
50/// Intended for local testing against `http://` mock servers or a
51/// trusted internal proxy — never for production credentials.
52pub const ALLOW_INSECURE_URL_ENV: &str = "SANDOGASA_ALLOW_INSECURE_URL";
53
54/// Refuse to hand credentials to a base URL that would transmit
55/// them in cleartext.
56///
57/// Returns `Ok(())` when the URL is `https`, when its host is a
58/// loopback address (`localhost`, `127.0.0.0/8`, `::1` — so mock
59/// servers and local development keep working), or when
60/// [`ALLOW_INSECURE_URL_ENV`] is set to a non-empty value.
61/// Otherwise returns an error naming the URL and the override, so
62/// an API token is never put on the wire over plain `http`.
63///
64/// Call this wherever a client is built with a token, before any
65/// request is made.
66pub fn ensure_secure_url(base_url: &str) -> Result<(), String> {
67 let allow_insecure = std::env::var_os(ALLOW_INSECURE_URL_ENV).is_some_and(|v| !v.is_empty());
68 check_secure_url(base_url, allow_insecure)
69}
70
71/// Pure core of [`ensure_secure_url`], with the env override passed
72/// in so it can be unit-tested without mutating process state.
73fn check_secure_url(base_url: &str, allow_insecure: bool) -> Result<(), String> {
74 let parsed = Url::parse(base_url).map_err(|e| format!("invalid URL '{base_url}': {e}"))?;
75 if parsed.scheme() == "https" || host_is_loopback(&parsed) {
76 return Ok(());
77 }
78 if allow_insecure {
79 return Ok(());
80 }
81 Err(format!(
82 "refusing to send credentials to '{base_url}' over plaintext \
83 {}: use an https URL, or set {ALLOW_INSECURE_URL_ENV}=1 to \
84 override (e.g. for local testing against a mock server).",
85 parsed.scheme()
86 ))
87}
88
89/// Whether a URL's host is a loopback address.
90fn host_is_loopback(u: &Url) -> bool {
91 match u.host() {
92 Some(Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
93 Some(Host::Ipv4(ip)) => ip.is_loopback(),
94 Some(Host::Ipv6(ip)) => ip.is_loopback(),
95 None => false,
96 }
97}
98
99/// Check that an external tool is available in `$PATH`.
100///
101/// Runs `<name> <version_arg>` and returns `Ok(())` if it exits
102/// successfully, or an error message with the install hint.
103/// Most tools use `--version`; see [`require_tool`] for a
104/// convenience wrapper.
105///
106/// # Example
107///
108/// ```no_run
109/// // koji uses `version` subcommand instead of `--version`
110/// sandogasa_cli::require_tool_with_arg("koji", "version", "sudo dnf install koji").unwrap();
111/// ```
112pub fn require_tool_with_arg(
113 name: &str,
114 version_arg: &str,
115 install_hint: &str,
116) -> Result<(), String> {
117 match Command::new(name)
118 .arg(version_arg)
119 .stdout(Stdio::null())
120 .stderr(Stdio::null())
121 .status()
122 {
123 Ok(s) if s.success() => Ok(()),
124 Ok(s) => Err(format!(
125 "{name} exited with {s}; is it installed correctly? \
126 Install it with: {install_hint}"
127 )),
128 Err(_) => Err(format!("{name} not found. Install it with: {install_hint}")),
129 }
130}
131
132/// Check that an external tool is available in `$PATH`.
133///
134/// Runs `<name> --version` and returns `Ok(())` if it exits
135/// successfully, or an error message with the install hint.
136///
137/// For tools that use a different version probe (e.g. `koji version`
138/// instead of `koji --version`), use [`require_tool_with_arg`].
139///
140/// # Example
141///
142/// ```no_run
143/// sandogasa_cli::require_tool("fedrq", "sudo dnf install fedrq").unwrap();
144/// ```
145pub fn require_tool(name: &str, install_hint: &str) -> Result<(), String> {
146 require_tool_with_arg(name, "--version", install_hint)
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn require_missing_tool() {
155 let result = require_tool("nonexistent_tool_xyz_123", "magic install");
156 assert!(result.is_err());
157 let msg = result.unwrap_err();
158 assert!(msg.contains("nonexistent_tool_xyz_123"));
159 assert!(msg.contains("magic install"));
160 }
161
162 #[test]
163 fn require_available_tool() {
164 // `true` is a standard Unix utility that always succeeds.
165 // It doesn't support --version but some impls exit 0 anyway.
166 // Use `sh` which reliably exists and handles --version.
167 let result = require_tool("sh", "should already be installed");
168 // sh --version may or may not succeed depending on implementation,
169 // so just verify it doesn't panic.
170 let _ = result;
171 }
172
173 #[test]
174 fn secure_url_allows_https() {
175 assert!(check_secure_url("https://bugzilla.redhat.com", false).is_ok());
176 assert!(check_secure_url("https://gitlab.com/api/v4", false).is_ok());
177 }
178
179 #[test]
180 fn secure_url_allows_loopback_over_http() {
181 // Mock servers / local dev: loopback is fine over http.
182 assert!(check_secure_url("http://127.0.0.1:8080", false).is_ok());
183 assert!(check_secure_url("http://localhost:3000/api", false).is_ok());
184 assert!(check_secure_url("http://[::1]:9999", false).is_ok());
185 }
186
187 #[test]
188 fn secure_url_rejects_plaintext_remote() {
189 let err = check_secure_url("http://gitlab.example.com", false).unwrap_err();
190 assert!(err.contains("gitlab.example.com"));
191 assert!(err.contains(ALLOW_INSECURE_URL_ENV));
192 }
193
194 #[test]
195 fn secure_url_override_allows_plaintext_remote() {
196 // With the override "set", plaintext to a remote host is allowed.
197 assert!(check_secure_url("http://gitlab.example.com", true).is_ok());
198 }
199
200 #[test]
201 fn secure_url_rejects_invalid() {
202 assert!(check_secure_url("not a url", false).is_err());
203 }
204}