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() {
32 install_crypto_provider();
33}
34
35pub fn install_crypto_provider() {
53 let _ = rustls::crypto::ring::default_provider().install_default();
54}
55
56pub const ALLOW_INSECURE_URL_ENV: &str = "SANDOGASA_ALLOW_INSECURE_URL";
61
62pub 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
79fn 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
97fn 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
107pub 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
115fn 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
130pub 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
166pub 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
199pub 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
217fn 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 assert!(require_tools(&[("sh", "present", None)]).is_ok());
243 assert!(require_tools(&[("nonexistent_zzz", "install zzz", None)]).is_err());
244
245 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 assert!(wrapped.lines().all(|l| l.starts_with("> ")));
268 assert!(wrapped.lines().all(|l| l.chars().count() <= 20));
269 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 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 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 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 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 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}