use std::env; use std::fmt::Write as _; use std::io; use std::os::unix::process::CommandExt; use std::path::Path; use std::process::Command; use crate::config::{Config, Rule}; use crate::url::Target; /// A command furst is prepared to run, and where it came from. struct Candidate { label: String, argv: Vec, } /// Never returns `Ok`: on success this process has been replaced by the handler. pub fn dispatch(raw: &str) -> Result { let cfg = Config::load()?; let target = Target::parse(raw); let candidates = candidates(&cfg, &target); if candidates.is_empty() { return Err(format!("no rule matched {raw}, and no default is set")); } let mut skipped: Vec = Vec::new(); for c in &candidates { // exec replaces this process, so it only returns on failure. let err = Command::new(&c.argv[0]).args(&c.argv[1..]).exec(); if err.kind() == io::ErrorKind::NotFound { skipped.push(c.argv[0].clone()); continue; } return Err(format!("{}: {err}", c.argv[0])); } Err(format!("nothing to run for {raw} (not installed: {})", skipped.join(", "))) } pub fn explain(raw: &str) -> Result { let cfg = Config::load()?; let target = Target::parse(raw); let mut o = String::new(); let dash = |s: &str| if s.is_empty() { "-".to_string() } else { s.to_string() }; let _ = writeln!(o, "scheme {}", dash(&target.scheme)); let _ = writeln!(o, "host {}", dash(&target.host)); let _ = writeln!(o, "path {}\n", dash(&target.path)); let candidates = candidates(&cfg, &target); if candidates.is_empty() { let _ = writeln!(o, "no rule matched, and no default is set"); return Ok(o); } // The first candidate that is actually installed is the one that runs. let winner = candidates.iter().position(|c| which(&c.argv[0])); for (i, c) in candidates.iter().enumerate() { let arrow = if Some(i) == winner { "->" } else { " " }; let note = if which(&c.argv[0]) { "" } else { " (not installed)" }; let _ = writeln!(o, "{arrow} [{}] {}{}", c.label, quote(&c.argv), note); } if winner.is_none() { let _ = writeln!(o, "\nnone of these are installed"); } Ok(o) } pub fn list() -> Result { let cfg = Config::load()?; let mut o = String::new(); for r in &cfg.rules { let _ = writeln!(o, "{}", r.name); for (k, v) in [ ("schemes", &r.schemes), ("hosts", &r.hosts), ("paths", &r.paths), ("contains", &r.contains), ] { if !v.is_empty() { let _ = writeln!(o, " {k:<9}{}", v.join(", ")); } } let note = if which(&r.run[0]) { "" } else { " (not installed)" }; let _ = writeln!(o, " {:<9}{}{}", "run", quote(&r.run), note); } if !cfg.default.is_empty() { let _ = writeln!(o, "default\n {:<9}{}", "run", quote(&cfg.default)); } Ok(o) } fn candidates(cfg: &Config, t: &Target) -> Vec { let mut out: Vec = cfg .rules .iter() .filter(|r| matches(r, t)) .map(|r| Candidate { label: r.name.clone(), argv: build(&r.run, r.terminal, t) }) .collect(); if !cfg.default.is_empty() { out.push(Candidate { label: "default".to_string(), argv: build(&cfg.default, false, t) }); } out } /// A rule matches when every criterion it *states* is satisfied. A criterion is /// satisfied by any one of its patterns. A rule that states nothing matches all. fn matches(r: &Rule, t: &Target) -> bool { let ok = |pats: &Vec, f: &dyn Fn(&str) -> bool| pats.is_empty() || pats.iter().any(|p| f(p)); ok(&r.schemes, &|p| p.eq_ignore_ascii_case(&t.scheme)) && ok(&r.hosts, &|p| host_matches(p, &t.host)) && ok(&r.paths, &|p| glob(&p.to_ascii_lowercase(), &t.path.to_ascii_lowercase())) && ok(&r.contains, &|p| t.raw.contains(p)) } fn host_matches(pat: &str, host: &str) -> bool { if pat == "*" { return true; } if let Some(exact) = pat.strip_prefix('=') { return exact.eq_ignore_ascii_case(host); } let base = pat.strip_prefix("*.").unwrap_or(pat).to_ascii_lowercase(); // Domain match: the apex plus every subdomain under it. host == base || host.ends_with(&format!(".{base}")) } /// Glob with `*` as the only metacharacter. fn glob(pat: &str, s: &str) -> bool { let parts: Vec<&str> = pat.split('*').collect(); if parts.len() == 1 { return pat == s; } if !s.starts_with(parts[0]) { return false; } let mut pos = parts[0].len(); let last = parts.len() - 1; for (i, part) in parts.iter().enumerate().skip(1) { if i == last { return part.is_empty() || (s.len() >= pos + part.len() && s.ends_with(part)); } if part.is_empty() { continue; } match s[pos..].find(part) { Some(j) => pos += j + part.len(), None => return false, } } true } fn build(run: &[String], terminal: bool, t: &Target) -> Vec { let mut argv: Vec = run.iter().map(|a| subst(a, t)).collect(); // Convenience: a rule that never names the URL still gets it, last. if !run.iter().any(|a| a.contains("{url}") || a.contains("{url_enc}")) { argv.push(t.raw.clone()); } if terminal && let Some(mut term) = terminal_cmd() { term.extend(argv); return term; } argv } fn subst(arg: &str, t: &Target) -> String { arg.replace("{url_enc}", &percent_encode(&t.raw)) .replace("{url}", &t.raw) .replace("{host}", &t.host) .replace("{path}", &t.path) .replace("{scheme}", &t.scheme) } fn percent_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out } fn terminal_cmd() -> Option> { if let Ok(t) = env::var("TERMINAL") && !t.is_empty() { return Some(vec![t, "-e".to_string()]); } ["foot", "alacritty", "kitty", "wezterm", "urxvt", "st", "xterm"] .iter() .find(|t| which(t)) .map(|t| vec![t.to_string(), "-e".to_string()]) } pub fn which(cmd: &str) -> bool { if cmd.contains('/') { return Path::new(cmd).is_file(); } env::var_os("PATH").is_some_and(|paths| env::split_paths(&paths).any(|d| d.join(cmd).is_file())) } /// Display-only quoting, so `--explain` output can be pasted into a shell. fn quote(argv: &[String]) -> String { argv.iter() .map(|a| { if a.is_empty() || a.contains([' ', '\t', '"', '\'', '$', '&', ';', '|', '<', '>', '*', '?']) { format!("'{}'", a.replace('\'', r"'\''")) } else { a.clone() } }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn host_domain_match_covers_apex_and_subdomains() { assert!(host_matches("example.com", "example.com")); assert!(host_matches("example.com", "www.example.com")); assert!(host_matches("*.example.com", "a.b.example.com")); // Must not match a suffix that is not a domain boundary. assert!(!host_matches("example.com", "notexample.com")); assert!(!host_matches("example.com", "example.com.evil.net")); } #[test] fn exact_host_excludes_subdomains() { assert!(host_matches("=example.com", "example.com")); assert!(!host_matches("=example.com", "www.example.com")); } #[test] fn globs() { assert!(glob("*.pdf", "/doc/a.pdf")); assert!(!glob("*.pdf", "/doc/a.pdfx")); assert!(glob("/a/*/z*", "/a/bbb/zzz.html")); assert!(!glob("/a/*/z*", "/a/bbb/yyy")); assert!(glob("*", "")); assert!(glob("/exact", "/exact")); assert!(!glob("/exact", "/exact/more")); } #[test] fn userinfo_cannot_spoof_the_host() { let t = Target::parse("https://meet.google.com@evil.example/x"); assert_eq!(t.host, "evil.example"); } #[test] fn parses_ports_ipv6_and_opaque_schemes() { assert_eq!(Target::parse("http://h.test:8080/p?q=1").host, "h.test"); assert_eq!(Target::parse("http://[::1]:80/p").host, "[::1]"); assert_eq!(Target::parse("https://H.Test./P").host, "h.test"); let m = Target::parse("mailto:a@b.test"); assert_eq!(m.scheme, "mailto"); assert!(m.host.is_empty()); // A bare domain is treated as https, the way a browser would. assert_eq!(Target::parse("example.com/x").scheme, "https"); } #[test] fn url_is_appended_when_the_rule_never_names_it() { let t = Target::parse("https://h.test/"); let argv = build(&["mpv".to_string()], false, &t); assert_eq!(argv, vec!["mpv", "https://h.test/"]); // ...but not when it does. let argv = build(&["mpv".to_string(), "{url}".to_string()], false, &t); assert_eq!(argv.len(), 2); } #[test] fn empty_criteria_match_everything() { let r = Rule { name: "catch-all".into(), schemes: vec![], hosts: vec![], paths: vec![], contains: vec![], terminal: false, run: vec!["x".into()], }; assert!(matches(&r, &Target::parse("https://anything.test/"))); } }