Sits where the default browser used to and dispatches each URL to the
cheapest tool that can handle it: mpv for video, zathura for PDFs, a light
WebKit browser for reading, Firefox only when nothing else will do.
Rules live in ~/.config/furst/rules.toml and are matched in order. When a
rule's command is missing from $PATH the router falls through to the next
match, then to the default, so a config may name tools that do not exist
yet without breaking today.
- host patterns match apex plus subdomains, with = for exact and * for any
- paths glob case-insensitively, contains matches the raw URL
- {url} {url_enc} {host} {path} {scheme} placeholders, URL appended if unused
- terminal = true wraps a handler in $TERMINAL -e for TUI tools
- --explain shows the parse and every candidate in priority order
- --install registers a .desktop entry as the system default browser
Host parsing strips userinfo with rfind('@') so that
https://bank.example@evil.example/ routes on evil.example. Handlers are
exec'd rather than forked, leaving no process behind.
288 lines
9.5 KiB
Rust
288 lines
9.5 KiB
Rust
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<String>,
|
|
}
|
|
|
|
/// Never returns `Ok`: on success this process has been replaced by the handler.
|
|
pub fn dispatch(raw: &str) -> Result<String, String> {
|
|
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<String> = 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<String, String> {
|
|
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<String, String> {
|
|
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<Candidate> {
|
|
let mut out: Vec<Candidate> = 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<String>, 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<String> {
|
|
let mut argv: Vec<String> = 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<Vec<String>> {
|
|
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::<Vec<_>>()
|
|
.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/")));
|
|
}
|
|
}
|