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.
This commit is contained in:
nak0x 2026-09-06 19:18:13 +02:00
commit ec79c07b0f
10 changed files with 981 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

156
Cargo.lock generated Normal file
View File

@ -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",
]

16
Cargo.toml Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

102
README.md Normal file
View File

@ -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 25MB across 80+ requests with 13MB of JavaScript to parse and
JIT. The same article extracted is ~20KB. Choosing a lighter *engine* buys
23×; not loading the payload at all buys 10100×. `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 <url> # match a rule and exec its command
furst --explain <url> # 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

189
src/config.rs Normal file
View File

@ -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<String>,
#[serde(default, rename = "rule")]
pub rules: Vec<Rule>,
}
#[derive(Debug, Deserialize)]
pub struct Rule {
#[serde(default = "unnamed")]
pub name: String,
#[serde(default)]
pub schemes: Vec<String>,
#[serde(default)]
pub hosts: Vec<String>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub contains: Vec<String>,
/// Wrap the command in $TERMINAL -e, for TUI handlers.
#[serde(default)]
pub terminal: bool,
pub run: Vec<String>,
}
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<Config, String> {
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<String, String> {
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<String> = 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}"]
"##;

58
src/install.rs Normal file
View File

@ -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<String, String> {
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)"),
};
}

65
src/main.rs Normal file
View File

@ -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 <url> match a rule and exec its command
furst --explain <url> 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<String> = 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}");
}
}

287
src/route.rs Normal file
View File

@ -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<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/")));
}
}

86
src/url.rs Normal file
View File

@ -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()
}