1pub 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
19pub fn init() {
33 #[cfg(feature = "tls")]
34 install_crypto_provider();
35}
36
37#[cfg(feature = "tls")]
55pub fn install_crypto_provider() {
56 let _ = rustls::crypto::ring::default_provider().install_default();
57}
58
59pub const ALLOW_INSECURE_URL_ENV: &str = "SANDOGASA_ALLOW_INSECURE_URL";
64
65pub fn ensure_secure_url(base_url: &str) -> Result<(), String> {
78 let allow_insecure = std::env::var_os(ALLOW_INSECURE_URL_ENV).is_some_and(|v| !v.is_empty());
79 check_secure_url(base_url, allow_insecure)
80}
81
82fn check_secure_url(base_url: &str, allow_insecure: bool) -> Result<(), String> {
85 let parsed = Url::parse(base_url).map_err(|e| format!("invalid URL '{base_url}': {e}"))?;
86 if parsed.scheme() == "https" || host_is_loopback(&parsed) {
87 return Ok(());
88 }
89 if allow_insecure {
90 return Ok(());
91 }
92 Err(format!(
93 "refusing to send credentials to '{base_url}' over plaintext \
94 {}: use an https URL, or set {ALLOW_INSECURE_URL_ENV}=1 to \
95 override (e.g. for local testing against a mock server).",
96 parsed.scheme()
97 ))
98}
99
100fn host_is_loopback(u: &Url) -> bool {
102 match u.host() {
103 Some(Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
104 Some(Host::Ipv4(ip)) => ip.is_loopback(),
105 Some(Host::Ipv6(ip)) => ip.is_loopback(),
106 None => false,
107 }
108}
109
110pub fn tool_exists(name: &str) -> bool {
113 std::env::var_os("PATH")
114 .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file()))
115 .unwrap_or(false)
116}
117
118fn tool_available(exe: &str, probe: Option<&str>) -> bool {
122 match probe {
123 Some(arg) => Command::new(exe)
124 .arg(arg)
125 .stdout(Stdio::null())
126 .stderr(Stdio::null())
127 .status()
128 .is_ok_and(|s| s.success()),
129 None => tool_exists(exe),
130 }
131}
132
133pub fn require_tools(tools: &[(&str, &str, Option<&str>)]) -> Result<(), String> {
157 let missing: Vec<String> = tools
158 .iter()
159 .filter(|(exe, _, probe)| !tool_available(exe, *probe))
160 .map(|(exe, hint, _)| format!("{exe} (install: {hint})"))
161 .collect();
162 if missing.is_empty() {
163 Ok(())
164 } else {
165 Err(format!("missing required tool(s): {}", missing.join(", ")))
166 }
167}
168
169pub fn wrap_prefixed(text: &str, prefix: &str, width: usize) -> String {
178 let mut out = String::new();
179 let mut line = String::new();
180 for word in text.split_whitespace() {
181 if !line.is_empty()
182 && prefix.len() + line.len() + 1 + word.len() > width
183 && prefix.len() + word.len() <= width
184 {
185 out.push_str(prefix);
186 out.push_str(&line);
187 out.push('\n');
188 line.clear();
189 }
190 if !line.is_empty() {
191 line.push(' ');
192 }
193 line.push_str(word);
194 }
195 if !line.is_empty() {
196 out.push_str(prefix);
197 out.push_str(&line);
198 }
199 out
200}
201
202pub fn confirm(question: &str, default_yes: bool) -> std::io::Result<bool> {
211 use std::io::{BufRead, Write};
212 let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
213 eprint!("{question} {hint}: ");
214 std::io::stderr().flush()?;
215 let mut line = String::new();
216 std::io::stdin().lock().read_line(&mut line)?;
217 Ok(parse_confirm(&line, default_yes))
218}
219
220fn parse_confirm(answer: &str, default_yes: bool) -> bool {
222 let answer = answer.trim();
223 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
224 true
225 } else if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") {
226 false
227 } else {
228 default_yes
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn tool_exists_detects_present_and_absent() {
238 assert!(tool_exists("sh"));
239 assert!(!tool_exists("nonexistent_tool_xyz_123"));
240 }
241
242 #[test]
243 fn require_tools_path_and_probe_modes() {
244 assert!(require_tools(&[("sh", "present", None)]).is_ok());
246 assert!(require_tools(&[("nonexistent_zzz", "install zzz", None)]).is_err());
247
248 assert!(require_tools(&[("true", "ok", Some("--version"))]).is_ok());
252 let err = require_tools(&[
253 ("true", "ok", Some("--version")),
254 ("nonexistent_aaa_111", "install aaa", Some("--version")),
255 ("nonexistent_bbb_222", "install bbb", None),
256 ])
257 .unwrap_err();
258 assert!(err.contains("nonexistent_aaa_111"));
259 assert!(err.contains("install aaa"));
260 assert!(err.contains("nonexistent_bbb_222"));
261 assert!(err.contains("install bbb"));
262 assert!(!err.contains("true ("));
263 }
264
265 #[test]
266 fn wrap_prefixed_wraps_and_prefixes() {
267 let text = "alpha beta gamma delta epsilon zeta eta theta iota";
268 let wrapped = wrap_prefixed(text, "> ", 20);
269 assert!(wrapped.lines().all(|l| l.starts_with("> ")));
271 assert!(wrapped.lines().all(|l| l.chars().count() <= 20));
272 assert!(wrapped.lines().count() > 1);
274 assert_eq!(
275 wrapped.split_whitespace().count(),
276 9 + wrapped.lines().count()
277 );
278 }
279
280 #[test]
281 fn wrap_prefixed_keeps_a_long_word_whole_and_in_place() {
282 let url = "https://example.com/a/very/long/path/that/exceeds/the/width";
285 let wrapped = wrap_prefixed(&format!("LINK: {url} please"), " ", 20);
286 assert!(wrapped.contains(url), "{wrapped}");
287 assert_eq!(wrapped.lines().next().unwrap(), format!(" LINK: {url}"));
288 assert_eq!(wrapped.lines().nth(1).unwrap(), " please");
290 }
291
292 #[test]
293 fn parse_confirm_answers_and_defaults() {
294 for yes in ["y", "Y", "yes", "YES", " y "] {
295 assert!(parse_confirm(yes, false));
296 }
297 for no in ["n", "N", "no", "NO"] {
298 assert!(!parse_confirm(no, true));
299 }
300 for other in ["", "\n", "maybe"] {
302 assert!(parse_confirm(other, true));
303 assert!(!parse_confirm(other, false));
304 }
305 }
306
307 #[test]
308 fn secure_url_allows_https() {
309 assert!(check_secure_url("https://bugzilla.redhat.com", false).is_ok());
310 assert!(check_secure_url("https://gitlab.com/api/v4", false).is_ok());
311 }
312
313 #[test]
314 fn secure_url_allows_loopback_over_http() {
315 assert!(check_secure_url("http://127.0.0.1:8080", false).is_ok());
317 assert!(check_secure_url("http://localhost:3000/api", false).is_ok());
318 assert!(check_secure_url("http://[::1]:9999", false).is_ok());
319 }
320
321 #[test]
322 fn secure_url_rejects_plaintext_remote() {
323 let err = check_secure_url("http://gitlab.example.com", false).unwrap_err();
324 assert!(err.contains("gitlab.example.com"));
325 assert!(err.contains(ALLOW_INSECURE_URL_ENV));
326 }
327
328 #[test]
329 fn secure_url_override_allows_plaintext_remote() {
330 assert!(check_secure_url("http://gitlab.example.com", true).is_ok());
332 }
333
334 #[test]
335 fn secure_url_rejects_invalid() {
336 assert!(check_secure_url("not a url", false).is_err());
337 }
338}