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