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:
commit
ec79c07b0f
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
156
Cargo.lock
generated
Normal file
156
Cargo.lock
generated
Normal 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
16
Cargo.toml
Normal 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
21
LICENSE
Normal 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
102
README.md
Normal 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 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 <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
189
src/config.rs
Normal 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
58
src/install.rs
Normal 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
65
src/main.rs
Normal 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
287
src/route.rs
Normal 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
86
src/url.rs
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user