1pub mod date;
6pub mod defaults;
7#[cfg(feature = "http")]
8pub mod http;
9#[cfg(feature = "man")]
10pub mod man;
11
12pub use defaults::parse_with_defaults;
13
14use std::process::{Command, Stdio};
15
16use url::{Host, Url};
17
18pub fn init() {
31 install_crypto_provider();
32}
33
34pub fn install_crypto_provider() {
52 let _ = rustls::crypto::ring::default_provider().install_default();
53}
54
55pub const ALLOW_INSECURE_URL_ENV: &str = "SANDOGASA_ALLOW_INSECURE_URL";
60
61pub fn ensure_secure_url(base_url: &str) -> Result<(), String> {
74 let allow_insecure = std::env::var_os(ALLOW_INSECURE_URL_ENV).is_some_and(|v| !v.is_empty());
75 check_secure_url(base_url, allow_insecure)
76}
77
78fn check_secure_url(base_url: &str, allow_insecure: bool) -> Result<(), String> {
81 let parsed = Url::parse(base_url).map_err(|e| format!("invalid URL '{base_url}': {e}"))?;
82 if parsed.scheme() == "https" || host_is_loopback(&parsed) {
83 return Ok(());
84 }
85 if allow_insecure {
86 return Ok(());
87 }
88 Err(format!(
89 "refusing to send credentials to '{base_url}' over plaintext \
90 {}: use an https URL, or set {ALLOW_INSECURE_URL_ENV}=1 to \
91 override (e.g. for local testing against a mock server).",
92 parsed.scheme()
93 ))
94}
95
96fn host_is_loopback(u: &Url) -> bool {
98 match u.host() {
99 Some(Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"),
100 Some(Host::Ipv4(ip)) => ip.is_loopback(),
101 Some(Host::Ipv6(ip)) => ip.is_loopback(),
102 None => false,
103 }
104}
105
106pub fn tool_exists(name: &str) -> bool {
109 std::env::var_os("PATH")
110 .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file()))
111 .unwrap_or(false)
112}
113
114fn tool_available(exe: &str, probe: Option<&str>) -> bool {
118 match probe {
119 Some(arg) => Command::new(exe)
120 .arg(arg)
121 .stdout(Stdio::null())
122 .stderr(Stdio::null())
123 .status()
124 .is_ok_and(|s| s.success()),
125 None => tool_exists(exe),
126 }
127}
128
129pub fn require_tools(tools: &[(&str, &str, Option<&str>)]) -> Result<(), String> {
153 let missing: Vec<String> = tools
154 .iter()
155 .filter(|(exe, _, probe)| !tool_available(exe, *probe))
156 .map(|(exe, hint, _)| format!("{exe} (install: {hint})"))
157 .collect();
158 if missing.is_empty() {
159 Ok(())
160 } else {
161 Err(format!("missing required tool(s): {}", missing.join(", ")))
162 }
163}
164
165pub fn wrap_prefixed(text: &str, prefix: &str, width: usize) -> String {
174 let mut out = String::new();
175 let mut line = String::new();
176 for word in text.split_whitespace() {
177 if !line.is_empty()
178 && prefix.len() + line.len() + 1 + word.len() > width
179 && prefix.len() + word.len() <= width
180 {
181 out.push_str(prefix);
182 out.push_str(&line);
183 out.push('\n');
184 line.clear();
185 }
186 if !line.is_empty() {
187 line.push(' ');
188 }
189 line.push_str(word);
190 }
191 if !line.is_empty() {
192 out.push_str(prefix);
193 out.push_str(&line);
194 }
195 out
196}
197
198pub fn confirm(question: &str, default_yes: bool) -> std::io::Result<bool> {
207 use std::io::{BufRead, Write};
208 let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
209 eprint!("{question} {hint}: ");
210 std::io::stderr().flush()?;
211 let mut line = String::new();
212 std::io::stdin().lock().read_line(&mut line)?;
213 Ok(parse_confirm(&line, default_yes))
214}
215
216fn parse_confirm(answer: &str, default_yes: bool) -> bool {
218 let answer = answer.trim();
219 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
220 true
221 } else if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") {
222 false
223 } else {
224 default_yes
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn tool_exists_detects_present_and_absent() {
234 assert!(tool_exists("sh"));
235 assert!(!tool_exists("nonexistent_tool_xyz_123"));
236 }
237
238 #[test]
239 fn require_tools_path_and_probe_modes() {
240 assert!(require_tools(&[("sh", "present", None)]).is_ok());
242 assert!(require_tools(&[("nonexistent_zzz", "install zzz", None)]).is_err());
243
244 assert!(require_tools(&[("true", "ok", Some("--version"))]).is_ok());
248 let err = require_tools(&[
249 ("true", "ok", Some("--version")),
250 ("nonexistent_aaa_111", "install aaa", Some("--version")),
251 ("nonexistent_bbb_222", "install bbb", None),
252 ])
253 .unwrap_err();
254 assert!(err.contains("nonexistent_aaa_111"));
255 assert!(err.contains("install aaa"));
256 assert!(err.contains("nonexistent_bbb_222"));
257 assert!(err.contains("install bbb"));
258 assert!(!err.contains("true ("));
259 }
260
261 #[test]
262 fn wrap_prefixed_wraps_and_prefixes() {
263 let text = "alpha beta gamma delta epsilon zeta eta theta iota";
264 let wrapped = wrap_prefixed(text, "> ", 20);
265 assert!(wrapped.lines().all(|l| l.starts_with("> ")));
267 assert!(wrapped.lines().all(|l| l.chars().count() <= 20));
268 assert!(wrapped.lines().count() > 1);
270 assert_eq!(
271 wrapped.split_whitespace().count(),
272 9 + wrapped.lines().count()
273 );
274 }
275
276 #[test]
277 fn wrap_prefixed_keeps_a_long_word_whole_and_in_place() {
278 let url = "https://example.com/a/very/long/path/that/exceeds/the/width";
281 let wrapped = wrap_prefixed(&format!("LINK: {url} please"), " ", 20);
282 assert!(wrapped.contains(url), "{wrapped}");
283 assert_eq!(wrapped.lines().next().unwrap(), format!(" LINK: {url}"));
284 assert_eq!(wrapped.lines().nth(1).unwrap(), " please");
286 }
287
288 #[test]
289 fn parse_confirm_answers_and_defaults() {
290 for yes in ["y", "Y", "yes", "YES", " y "] {
291 assert!(parse_confirm(yes, false));
292 }
293 for no in ["n", "N", "no", "NO"] {
294 assert!(!parse_confirm(no, true));
295 }
296 for other in ["", "\n", "maybe"] {
298 assert!(parse_confirm(other, true));
299 assert!(!parse_confirm(other, false));
300 }
301 }
302
303 #[test]
304 fn secure_url_allows_https() {
305 assert!(check_secure_url("https://bugzilla.redhat.com", false).is_ok());
306 assert!(check_secure_url("https://gitlab.com/api/v4", false).is_ok());
307 }
308
309 #[test]
310 fn secure_url_allows_loopback_over_http() {
311 assert!(check_secure_url("http://127.0.0.1:8080", false).is_ok());
313 assert!(check_secure_url("http://localhost:3000/api", false).is_ok());
314 assert!(check_secure_url("http://[::1]:9999", false).is_ok());
315 }
316
317 #[test]
318 fn secure_url_rejects_plaintext_remote() {
319 let err = check_secure_url("http://gitlab.example.com", false).unwrap_err();
320 assert!(err.contains("gitlab.example.com"));
321 assert!(err.contains(ALLOW_INSECURE_URL_ENV));
322 }
323
324 #[test]
325 fn secure_url_override_allows_plaintext_remote() {
326 assert!(check_secure_url("http://gitlab.example.com", true).is_ok());
328 }
329
330 #[test]
331 fn secure_url_rejects_invalid() {
332 assert!(check_secure_url("not a url", false).is_err());
333 }
334}