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 claim;
6pub mod date;
7pub mod defaults;
8#[cfg(feature = "http")]
9pub mod http;
10#[cfg(feature = "man")]
11pub mod man;
12
13pub use defaults::parse_with_defaults;
14
15use std::process::{Command, Stdio};
16
17use url::{Host, Url};
18
19/// Standard process-wide initialization for sandogasa tools.
20///
21/// Call this once as the first statement of `main()` in every
22/// binary. It is the single place for cross-cutting startup work:
23/// anything added to this function is automatically picked up by
24/// every tool that calls it, so prefer extending `init` over
25/// scattering setup across mains.
26///
27/// Today it registers the rustls crypto provider that reqwest's
28/// TLS support needs (see [`install_crypto_provider`]). Idempotent
29/// and cheap, so calling it from a tool that does no networking is
30/// harmless.
31pub fn init() {
32    install_crypto_provider();
33}
34
35/// Install the ring-based rustls [`CryptoProvider`] as the process
36/// default.
37///
38/// We build reqwest with the `rustls-no-provider` feature to keep
39/// `aws-lc-rs` — reqwest 0.13's default provider, which is not
40/// packaged in Fedora — out of the dependency tree. That leaves
41/// rustls with no compiled-in default provider, so one must be
42/// registered at runtime before the first HTTPS request or reqwest
43/// panics with "No provider set". `ring` is statically linked into
44/// the binary (a build-time dependency only); this just points
45/// rustls at it.
46///
47/// Idempotent: the underlying `install_default` only takes effect
48/// on the first call and reports an error on subsequent ones, which
49/// we ignore so repeated calls (e.g. across tests) are harmless.
50///
51/// [`CryptoProvider`]: rustls::crypto::CryptoProvider
52pub fn install_crypto_provider() {
53    let _ = rustls::crypto::ring::default_provider().install_default();
54}
55
56/// Environment variable that, when set to a non-empty value,
57/// disables [`ensure_secure_url`]'s plaintext-credential guard.
58/// Intended for local testing against `http://` mock servers or a
59/// trusted internal proxy — never for production credentials.
60pub const ALLOW_INSECURE_URL_ENV: &str = "SANDOGASA_ALLOW_INSECURE_URL";
61
62/// Refuse to hand credentials to a base URL that would transmit
63/// them in cleartext.
64///
65/// Returns `Ok(())` when the URL is `https`, when its host is a
66/// loopback address (`localhost`, `127.0.0.0/8`, `::1` — so mock
67/// servers and local development keep working), or when
68/// [`ALLOW_INSECURE_URL_ENV`] is set to a non-empty value.
69/// Otherwise returns an error naming the URL and the override, so
70/// an API token is never put on the wire over plain `http`.
71///
72/// Call this wherever a client is built with a token, before any
73/// request is made.
74pub fn ensure_secure_url(base_url: &str) -> Result<(), String> {
75    let allow_insecure = std::env::var_os(ALLOW_INSECURE_URL_ENV).is_some_and(|v| !v.is_empty());
76    check_secure_url(base_url, allow_insecure)
77}
78
79/// Pure core of [`ensure_secure_url`], with the env override passed
80/// in so it can be unit-tested without mutating process state.
81fn check_secure_url(base_url: &str, allow_insecure: bool) -> Result<(), String> {
82    let parsed = Url::parse(base_url).map_err(|e| format!("invalid URL '{base_url}': {e}"))?;
83    if parsed.scheme() == "https" || host_is_loopback(&parsed) {
84        return Ok(());
85    }
86    if allow_insecure {
87        return Ok(());
88    }
89    Err(format!(
90        "refusing to send credentials to '{base_url}' over plaintext \
91         {}: use an https URL, or set {ALLOW_INSECURE_URL_ENV}=1 to \
92         override (e.g. for local testing against a mock server).",
93        parsed.scheme()
94    ))
95}
96
97/// Whether a URL's host is a loopback address.
98fn host_is_loopback(u: &Url) -> bool {
99    match u.host() {
100        Some(Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
101        Some(Host::Ipv4(ip)) => ip.is_loopback(),
102        Some(Host::Ipv6(ip)) => ip.is_loopback(),
103        None => false,
104    }
105}
106
107/// Whether an executable named `name` is on `$PATH` (a lightweight
108/// check that does **not** run the tool).
109pub fn tool_exists(name: &str) -> bool {
110    std::env::var_os("PATH")
111        .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file()))
112        .unwrap_or(false)
113}
114
115/// Whether `exe` is available, per its `probe`: `Some(arg)` runs
116/// `exe arg` and requires a zero exit (confirms it executes);
117/// `None` checks only `$PATH` existence.
118fn tool_available(exe: &str, probe: Option<&str>) -> bool {
119    match probe {
120        Some(arg) => Command::new(exe)
121            .arg(arg)
122            .stdout(Stdio::null())
123            .stderr(Stdio::null())
124            .status()
125            .is_ok_and(|s| s.success()),
126        None => tool_exists(exe),
127    }
128}
129
130/// Check that a batch of external tools is available, returning a
131/// single error that lists every missing one with its install hint.
132///
133/// Each entry is `(executable, install_hint, probe)`:
134/// - `probe = Some(arg)` *runs* `<executable> <arg>` (e.g.
135///   `Some("--version")`, or `Some("version")` for `koji`, or
136///   `Some("--help")` for `pbuilder-dist`) and requires a zero exit,
137///   confirming the tool actually executes.
138/// - `probe = None` checks only `$PATH` existence, for tools with no
139///   usable version/help flag.
140///
141/// All entries are checked, so the error names every missing tool
142/// rather than failing on the first.
143///
144/// # Example
145///
146/// ```no_run
147/// sandogasa_cli::require_tools(&[
148///     ("git", "sudo apt install git", Some("--version")),
149///     ("pbuilder-dist", "sudo apt install ubuntu-dev-tools", Some("--help")),
150/// ])
151/// .unwrap();
152/// ```
153pub fn require_tools(tools: &[(&str, &str, Option<&str>)]) -> Result<(), String> {
154    let missing: Vec<String> = tools
155        .iter()
156        .filter(|(exe, _, probe)| !tool_available(exe, *probe))
157        .map(|(exe, hint, _)| format!("{exe} (install: {hint})"))
158        .collect();
159    if missing.is_empty() {
160        Ok(())
161    } else {
162        Err(format!("missing required tool(s): {}", missing.join(", ")))
163    }
164}
165
166/// Word-wrap `text` to `width` columns and prefix every line with
167/// `prefix` (e.g. `"> "` for a Markdown blockquote, or the leading
168/// indent of a wrapped list item). Collapses runs of whitespace and
169/// never splits a word, so a single token longer than the width — a
170/// URL, typically — overflows rather than being broken. Such a token
171/// also stays on the line it started on: breaking before a word that
172/// won't fit on a fresh line either would only orphan whatever label
173/// introduces it (`LINK:`, `Minutes:`) while still overflowing.
174pub fn wrap_prefixed(text: &str, prefix: &str, width: usize) -> String {
175    let mut out = String::new();
176    let mut line = String::new();
177    for word in text.split_whitespace() {
178        if !line.is_empty()
179            && prefix.len() + line.len() + 1 + word.len() > width
180            && prefix.len() + word.len() <= width
181        {
182            out.push_str(prefix);
183            out.push_str(&line);
184            out.push('\n');
185            line.clear();
186        }
187        if !line.is_empty() {
188            line.push(' ');
189        }
190        line.push_str(word);
191    }
192    if !line.is_empty() {
193        out.push_str(prefix);
194        out.push_str(&line);
195    }
196    out
197}
198
199/// Ask a yes/no question on stderr (keeping stdout clean for piped
200/// or `--json` output) and read one line from stdin.
201///
202/// `y`/`yes` and `n`/`no` (any case) answer explicitly; anything
203/// else — including just Enter or EOF — takes the default. The
204/// prompt shows `[Y/n]` or `[y/N]` to match `default_yes`. Callers
205/// must not prompt when stdin isn't a terminal or in `--json` mode
206/// (see the CLI-behavior conventions).
207pub fn confirm(question: &str, default_yes: bool) -> std::io::Result<bool> {
208    use std::io::{BufRead, Write};
209    let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
210    eprint!("{question} {hint}: ");
211    std::io::stderr().flush()?;
212    let mut line = String::new();
213    std::io::stdin().lock().read_line(&mut line)?;
214    Ok(parse_confirm(&line, default_yes))
215}
216
217/// Pure core of [`confirm`], unit-testable without stdin.
218fn parse_confirm(answer: &str, default_yes: bool) -> bool {
219    let answer = answer.trim();
220    if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
221        true
222    } else if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") {
223        false
224    } else {
225        default_yes
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn tool_exists_detects_present_and_absent() {
235        assert!(tool_exists("sh"));
236        assert!(!tool_exists("nonexistent_tool_xyz_123"));
237    }
238
239    #[test]
240    fn require_tools_path_and_probe_modes() {
241        // PATH mode (probe None): present is OK, absent is missing.
242        assert!(require_tools(&[("sh", "present", None)]).is_ok());
243        assert!(require_tools(&[("nonexistent_zzz", "install zzz", None)]).is_err());
244
245        // Probe mode: `true` runs and exits 0; a missing executable
246        // fails the probe. The error lists every missing tool with its
247        // hint, and skips the present one.
248        assert!(require_tools(&[("true", "ok", Some("--version"))]).is_ok());
249        let err = require_tools(&[
250            ("true", "ok", Some("--version")),
251            ("nonexistent_aaa_111", "install aaa", Some("--version")),
252            ("nonexistent_bbb_222", "install bbb", None),
253        ])
254        .unwrap_err();
255        assert!(err.contains("nonexistent_aaa_111"));
256        assert!(err.contains("install aaa"));
257        assert!(err.contains("nonexistent_bbb_222"));
258        assert!(err.contains("install bbb"));
259        assert!(!err.contains("true ("));
260    }
261
262    #[test]
263    fn wrap_prefixed_wraps_and_prefixes() {
264        let text = "alpha beta gamma delta epsilon zeta eta theta iota";
265        let wrapped = wrap_prefixed(text, "> ", 20);
266        // Every line is prefixed and within width.
267        assert!(wrapped.lines().all(|l| l.starts_with("> ")));
268        assert!(wrapped.lines().all(|l| l.chars().count() <= 20));
269        // It actually wrapped (more than one line) and lost no words.
270        assert!(wrapped.lines().count() > 1);
271        assert_eq!(
272            wrapped.split_whitespace().count(),
273            9 + wrapped.lines().count()
274        );
275    }
276
277    #[test]
278    fn wrap_prefixed_keeps_a_long_word_whole_and_in_place() {
279        // A URL longer than the width overflows rather than breaking,
280        // and stays with the label that introduces it.
281        let url = "https://example.com/a/very/long/path/that/exceeds/the/width";
282        let wrapped = wrap_prefixed(&format!("LINK: {url} please"), "  ", 20);
283        assert!(wrapped.contains(url), "{wrapped}");
284        assert_eq!(wrapped.lines().next().unwrap(), format!("  LINK: {url}"));
285        // Wrapping resumes normally after the oversized word.
286        assert_eq!(wrapped.lines().nth(1).unwrap(), "  please");
287    }
288
289    #[test]
290    fn parse_confirm_answers_and_defaults() {
291        for yes in ["y", "Y", "yes", "YES", " y "] {
292            assert!(parse_confirm(yes, false));
293        }
294        for no in ["n", "N", "no", "NO"] {
295            assert!(!parse_confirm(no, true));
296        }
297        // Empty (Enter/EOF) and anything unrecognized take the default.
298        for other in ["", "\n", "maybe"] {
299            assert!(parse_confirm(other, true));
300            assert!(!parse_confirm(other, false));
301        }
302    }
303
304    #[test]
305    fn secure_url_allows_https() {
306        assert!(check_secure_url("https://bugzilla.redhat.com", false).is_ok());
307        assert!(check_secure_url("https://gitlab.com/api/v4", false).is_ok());
308    }
309
310    #[test]
311    fn secure_url_allows_loopback_over_http() {
312        // Mock servers / local dev: loopback is fine over http.
313        assert!(check_secure_url("http://127.0.0.1:8080", false).is_ok());
314        assert!(check_secure_url("http://localhost:3000/api", false).is_ok());
315        assert!(check_secure_url("http://[::1]:9999", false).is_ok());
316    }
317
318    #[test]
319    fn secure_url_rejects_plaintext_remote() {
320        let err = check_secure_url("http://gitlab.example.com", false).unwrap_err();
321        assert!(err.contains("gitlab.example.com"));
322        assert!(err.contains(ALLOW_INSECURE_URL_ENV));
323    }
324
325    #[test]
326    fn secure_url_override_allows_plaintext_remote() {
327        // With the override "set", plaintext to a remote host is allowed.
328        assert!(check_secure_url("http://gitlab.example.com", true).is_ok());
329    }
330
331    #[test]
332    fn secure_url_rejects_invalid() {
333        assert!(check_secure_url("not a url", false).is_err());
334    }
335}