commit ec79c07b0f0d3f6534b55416be2d2dcd52242413 Author: nak0x Date: Sun Sep 6 19:18:13 2026 +0200 Add furst, a URL router for low-end hardware 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ea1b9db --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,156 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "furst" +version = "0.1.0" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0a28fbd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "furst" +version = "0.1.0" +edition = "2024" +description = "Route URLs to the cheapest tool that can handle them" + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = { version = "0.8", default-features = false, features = ["parse"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a2efc8f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nak0x + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8dbb87 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# furst + +A URL router. It sits where your default browser used to, and sends each URL to +the cheapest tool that can actually handle it — `mpv` for video, a pager for +PDFs, a light WebKit browser for reading, and Firefox only when nothing else +will do. + +Built for a Core2 Duo with 4GB of RAM, where the browser is the problem. + +## Why + +A news page is 2–5MB across 80+ requests with 1–3MB of JavaScript to parse and +JIT. The same article extracted is ~20KB. Choosing a lighter *engine* buys +2–3×; not loading the payload at all buys 10–100×. `furst` is the dispatcher +that decides which of those you get, per URL. + +## Install + +```sh +cargo build --release +install -Dm755 target/release/furst ~/.local/bin/furst + +furst --init # writes ~/.config/furst/rules.toml, probes for a light browser +furst --install # registers furst as the system default browser +``` + +`--install` writes `~/.local/share/applications/furst.desktop` and points +`xdg-settings` at it, so every link click in every application routes here. + +## Use + +```sh +furst # match a rule and exec its command +furst --explain # show what would run, and why; run nothing +furst --list # show the loaded rules +``` + +`--explain` is the one you want when a URL goes somewhere surprising: + +``` +$ furst --explain https://youtu.be/abc123 +scheme https +host youtu.be +path /abc123 + +-> [video] mpv --ytdl-format=bestvideo[vcodec^=avc1][height<=?720]+... https://youtu.be/abc123 + [default] surf https://youtu.be/abc123 +``` + +## Rules + +`~/.config/furst/rules.toml`. First matching rule wins. **If its command is +missing from `$PATH`, furst falls through to the next matching rule, and +finally to `default`** — which is what lets you name tools you have not written +yet and have the config stay working today. + +```toml +default = ["surf", "{url}"] + +[[rule]] +name = "video" +hosts = ["youtube.com", "youtu.be"] +run = ["mpv", "--ytdl-format=bestvideo[vcodec^=avc1][height<=?720]+bestaudio/best", "{url}"] + +[[rule]] +name = "hn" +hosts = ["news.ycombinator.com"] +terminal = true # wrap in $TERMINAL -e +run = ["furst-hn", "{url}"] +``` + +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 +everything. + +| Key | Matches against | +|---|---| +| `schemes` | `https`, `mailto`, `magnet`, … | +| `hosts` | `example.com` = apex **and** every subdomain; `*.example.com` = same; `=example.com` = that host exactly; `*` = any | +| `paths` | path component only, glob with `*`, case-insensitive | +| `contains` | substring of the whole raw URL | + +| Placeholder | | +|---|---| +| `{url}` `{url_enc}` | the URL, raw or percent-encoded | +| `{host}` `{path}` `{scheme}` | parsed components | + +If no argument mentions `{url}` or `{url_enc}`, the URL is appended last. + +## Notes for old hardware + +- **Force H.264 for video.** A Core2 handles 720p `avc1` in software but stalls + on VP9/AV1, which is what YouTube serves by default. That format string is + doing more work than the resolution cap. +- Host matching strips userinfo with `rfind('@')`, so + `https://bank.example@evil.example/` routes on `evil.example`. +- `furst` `exec`s the handler rather than forking, so it leaves no process + behind. + +## License + +MIT diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1d66705 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,189 @@ +use std::env; +use std::fs; +use std::fmt::Write as _; +use std::io; +use std::path::PathBuf; + +use serde::Deserialize; + +use crate::route::which; + +/// Light browsers tried, in order, when `--init` picks a default. +const BROWSERS: &[&str] = &["surf", "luakit", "vimb", "badwolf", "epiphany", "firefox"]; + +#[derive(Debug, Deserialize)] +pub struct Config { + /// argv used when no rule matches, or every matching rule's command is missing. + #[serde(default)] + pub default: Vec, + #[serde(default, rename = "rule")] + pub rules: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Rule { + #[serde(default = "unnamed")] + pub name: String, + #[serde(default)] + pub schemes: Vec, + #[serde(default)] + pub hosts: Vec, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub contains: Vec, + /// Wrap the command in $TERMINAL -e, for TUI handlers. + #[serde(default)] + pub terminal: bool, + pub run: Vec, +} + +fn unnamed() -> String { + "unnamed".to_string() +} + +pub fn path() -> PathBuf { + if let Some(p) = env::var_os("FURST_CONFIG") { + return PathBuf::from(p); + } + env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")) + .join("furst") + .join("rules.toml") +} + +impl Config { + pub fn load() -> Result { + let p = path(); + let text = match fs::read_to_string(&p) { + Ok(t) => t, + // No config yet is not an error: the built-in starter is a usable one. + Err(e) if e.kind() == io::ErrorKind::NotFound => STARTER.to_string(), + Err(e) => return Err(format!("{}: {e}", p.display())), + }; + toml::from_str(&text).map_err(|e| format!("{}: {e}", p.display())) + } +} + +pub fn init(force: bool) -> Result { + let p = path(); + if p.exists() && !force { + return Err(format!("{} already exists (--init --force to overwrite)", p.display())); + } + if let Some(dir) = p.parent() { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + + let browser = BROWSERS.iter().find(|b| which(b)).copied().unwrap_or("surf"); + let text = STARTER.replacen("default = [\"surf\"", &format!("default = [\"{browser}\""), 1); + fs::write(&p, &text).map_err(|e| format!("{}: {e}", p.display()))?; + + let mut o = String::new(); + let _ = writeln!(o, "wrote {}", p.display()); + let _ = writeln!(o, "fallback browser: {browser}"); + + let cfg: Config = toml::from_str(&text).map_err(|e| e.to_string())?; + let mut missing: Vec = Vec::new(); + for cmd in cfg.rules.iter().filter_map(|r| r.run.first()) { + if !which(cmd) && !missing.contains(cmd) { + missing.push(cmd.clone()); + } + } + if !missing.is_empty() { + let _ = writeln!(o, "\nnot installed — those rules fall through to the next match:"); + for m in &missing { + let _ = writeln!(o, " {m}"); + } + } + Ok(o) +} + +pub const STARTER: &str = r##"# furst — route URLs to the cheapest tool that can handle them. +# +# First matching rule wins. If its command is missing from $PATH, furst falls +# through to the next matching rule, and finally to `default`. That is what +# lets you name tools you have not written yet. +# +# Host patterns example.com the apex and every subdomain +# *.example.com alias for the same +# =example.com that exact host only +# * anything +# Path/contains glob with * +# Placeholders {url} {url_enc} {host} {path} {scheme} +# If no argument mentions {url} or {url_enc}, the URL is appended. +# terminal=true wraps the command in $TERMINAL -e, for TUI handlers. + +default = ["surf", "{url}"] + +# ---------------------------------------------------------------- video --- +# Never let a browser decode video on a Core2. Forcing avc1 matters more than +# resolution does: this CPU handles 720p H.264 in software but stalls hard on +# VP9 and AV1, which is what YouTube serves by default. +[[rule]] +name = "video" +hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"] +run = [ + "mpv", + "--ytdl-format=bestvideo[vcodec^=avc1][height<=?720]+bestaudio/best[vcodec^=avc1]/best", + "{url}", +] + +[[rule]] +name = "media-file" +paths = ["*.mp4", "*.mkv", "*.webm", "*.mp3", "*.flac", "*.opus", "*.m3u8"] +run = ["mpv", "{url}"] + +[[rule]] +name = "image" +paths = ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.avif"] +run = ["nsxiv", "-a", "{url}"] + +[[rule]] +name = "pdf" +paths = ["*.pdf"] +run = ["zathura", "{url}"] + +# --------------------------------------------------------------- reading --- +# The 100x win. furst-read does not exist yet; until it does these fall +# through to `default` on their own. +[[rule]] +name = "reader" +hosts = [ + "wikipedia.org", "news.ycombinator.com", "lobste.rs", "medium.com", + "*.substack.com", "stackoverflow.com", "reddit.com", +] +run = ["furst-read", "{url}"] + +# ------------------------------------------------------------------ docs --- +[[rule]] +name = "docs" +hosts = [ + "docs.rs", "doc.rust-lang.org", "wiki.archlinux.org", "man7.org", + "github.io", "readthedocs.io", +] +run = ["surf", "{url}"] + +# ----------------------------------------------------------------- heavy --- +# The escape hatch. Only these are allowed to cost you 400MB. +[[rule]] +name = "heavy" +hosts = [ + "meet.google.com", "docs.google.com", "figma.com", "netflix.com", + "web.whatsapp.com", "gitlab.com", +] +run = ["firefox", "{url}"] + +# ----------------------------------------------------------- non-web --- +[[rule]] +name = "mail" +schemes = ["mailto"] +run = ["xdg-email", "{url}"] + +[[rule]] +name = "torrent" +schemes = ["magnet"] +run = ["transmission-remote", "-a", "{url}"] +"##; diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..7910a63 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,58 @@ +use std::env; +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +/// Register furst as the system's default browser, so every link click in +/// every application lands here first. +pub fn install() -> Result { + let exe = env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + let dir = data_home().join("applications"); + fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + + let file = dir.join("furst.desktop"); + let entry = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=furst\n\ + GenericName=Web Browser\n\ + Comment=Route URLs to the cheapest tool that can handle them\n\ + Exec={} %u\n\ + Terminal=false\n\ + Categories=Network;WebBrowser;\n\ + MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;\n", + exe.display() + ); + fs::write(&file, entry).map_err(|e| format!("{}: {e}", file.display()))?; + let mut o = String::new(); + let _ = writeln!(o, "wrote {}", file.display()); + + // None of these are fatal: the .desktop file alone is enough for most setups. + try_run(&mut o, "update-desktop-database", &[&dir.to_string_lossy()]); + try_run(&mut o, "xdg-settings", &["set", "default-web-browser", "furst.desktop"]); + try_run( + &mut o, + "xdg-mime", + &["default", "furst.desktop", "x-scheme-handler/http", "x-scheme-handler/https", "text/html"], + ); + + let _ = writeln!(o, "\nverify: xdg-settings get default-web-browser"); + Ok(o) +} + +fn data_home() -> PathBuf { + env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".local").join("share"))) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn try_run(o: &mut String, cmd: &str, args: &[&str]) { + let _ = match Command::new(cmd).args(args).status() { + Ok(s) if s.success() => writeln!(o, "ran {cmd}"), + Ok(s) => writeln!(o, "skip {cmd} (exit {})", s.code().unwrap_or(-1)), + Err(_) => writeln!(o, "skip {cmd} (not installed)"), + }; +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..497f19b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,65 @@ +mod config; +mod install; +mod route; +mod url; + +use std::env; +use std::io::{self, Write}; +use std::process::ExitCode; + +const USAGE: &str = "\ +furst — route URLs to the cheapest tool that can handle them + +USAGE + furst match a rule and exec its command + furst --explain show what would run, and why; run nothing + furst --list show the loaded rules + furst --init [--force] write a starter config tuned for low-end hardware + furst --install register furst as the system default browser + furst --config print the config path in use + +CONFIG + $FURST_CONFIG, else $XDG_CONFIG_HOME/furst/rules.toml, + else ~/.config/furst/rules.toml. Without one, a built-in default is used. +"; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + let force = args.iter().any(|a| a == "--force"); + + let result = match args.first().map(String::as_str) { + None | Some("-h") | Some("--help") => Ok(USAGE.to_string()), + Some("--config") => Ok(format!("{}\n", config::path().display())), + Some("--init") => config::init(force), + Some("--install") => install::install(), + Some("--list") => route::list(), + Some("--explain") => match args.get(1) { + Some(u) => route::explain(u), + None => Err("--explain needs a url".to_string()), + }, + Some(flag) if flag.starts_with('-') => Err(format!("unknown flag {flag} (--help for usage)")), + // Only returns at all if every candidate handler was missing. + Some(u) => route::dispatch(u), + }; + + match result { + Ok(text) => { + out(&text); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("furst: {e}"); + ExitCode::FAILURE + } + } +} + +/// One write, and a closed pipe (`furst --list | head`) is not an error. +fn out(text: &str) { + if let Err(e) = io::stdout().write_all(text.as_bytes()) { + if e.kind() == io::ErrorKind::BrokenPipe { + return; + } + eprintln!("furst: stdout: {e}"); + } +} diff --git a/src/route.rs b/src/route.rs new file mode 100644 index 0000000..573b27e --- /dev/null +++ b/src/route.rs @@ -0,0 +1,287 @@ +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/"))); + } +} diff --git a/src/url.rs b/src/url.rs new file mode 100644 index 0000000..7ecb9d8 --- /dev/null +++ b/src/url.rs @@ -0,0 +1,86 @@ +//! Just enough URL parsing to route on. Not a spec-compliant parser: it only +//! needs scheme/host/path, and it must never panic on junk input. + +#[derive(Debug, Clone)] +pub struct Target { + /// The URL exactly as it was handed to us. + pub raw: String, + pub scheme: String, + /// Lowercased, userinfo and port stripped. Empty for opaque schemes. + pub host: String, + /// Path component only, without query or fragment. + pub path: String, +} + +impl Target { + pub fn parse(raw: &str) -> Target { + let raw = raw.trim(); + let (scheme, rest, hierarchical) = split_scheme(raw); + + if !hierarchical { + // mailto:, magnet:, tel: ... no authority to speak of. + return Target { + raw: raw.to_string(), + scheme, + host: String::new(), + path: rest.to_string(), + }; + } + + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let after = &rest[authority_end..]; + let path_end = after.find(['?', '#']).unwrap_or(after.len()); + + Target { + raw: raw.to_string(), + scheme, + host: host_of(authority), + path: after[..path_end].to_string(), + } + } +} + +/// Returns (scheme, remainder, is_hierarchical). +fn split_scheme(raw: &str) -> (String, &str, bool) { + if let Some(i) = raw.find("://") + && is_scheme(&raw[..i]) + { + return (raw[..i].to_ascii_lowercase(), &raw[i + 3..], true); + } + if let Some(i) = raw.find(':') + && is_scheme(&raw[..i]) + { + return (raw[..i].to_ascii_lowercase(), &raw[i + 1..], false); + } + // Bare "example.com/x" — treat it as https, which is what a browser does. + (String::from("https"), raw, true) +} + +fn is_scheme(s: &str) -> bool { + !s.is_empty() + && s.starts_with(|c: char| c.is_ascii_alphabetic()) + && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) +} + +fn host_of(authority: &str) -> String { + // Strip userinfo. Deliberately rfind: `https://good.example@evil.example/` + // must resolve to evil.example, not good.example. + let hostport = match authority.rfind('@') { + Some(i) => &authority[i + 1..], + None => authority, + }; + let host = if hostport.starts_with('[') { + // IPv6 literal: keep the brackets, everything after ] is the port. + match hostport.find(']') { + Some(i) => &hostport[..=i], + None => hostport, + } + } else { + match hostport.rfind(':') { + Some(i) if hostport[i + 1..].bytes().all(|b| b.is_ascii_digit()) => &hostport[..i], + _ => hostport, + } + }; + host.trim_end_matches('.').to_ascii_lowercase() +}