Compare commits

...

103 Commits

Author SHA1 Message Date
Edward Shen 0f27cfb788
clippy 2023-01-13 00:29:42 -08:00
Edward Shen 768f944b36
Update deps 2023-01-13 00:18:00 -08:00
Edward Shen 1588deb073
Remove strip instructions 2022-06-02 23:36:33 -07:00
Edward Shen 8920c57341
Bump to 0.8.1 2022-06-02 23:28:12 -07:00
Edward Shen 0144cf5f50
Use tracing; use cow for hop redirects 2022-06-02 23:22:53 -07:00
Edward Shen 72e672bc73
Use tracing 2022-06-02 23:04:59 -07:00
Edward Shen 3561f488c1
release config optimizations 2022-06-02 22:50:21 -07:00
Edward Shen 4055b9dee4
Remove unused tokio features 2022-06-02 22:50:11 -07:00
Edward Shen f1d7797637
Reformat 2022-06-02 22:46:10 -07:00
Edward Shen 90ff4461a6
2021 idioms 2022-06-02 22:42:19 -07:00
Edward Shen ce592985ce
Remove all unwraps 2022-06-02 22:39:35 -07:00
Edward Shen 0132d32507
Remove unwraps 2022-06-02 22:29:09 -07:00
Edward Shen dc216a80d5
Remove clone 2022-06-02 22:24:46 -07:00
Edward Shen 531a7da636
Clippy 2022-06-02 22:23:35 -07:00
Edward Shen ce68f4dd42
Migrate to axum 2022-06-02 21:58:56 -07:00
Edward Shen 411854385c
Percent-escape single quote 2022-06-02 20:12:07 -07:00
Edward Shen 49e1c8ce0c
Partial dependency update 2022-06-02 20:08:04 -07:00
Edward Shen 15ab316963
update dependencies 2021-04-05 15:00:58 -04:00
Edward Shen 543c13a500
Add tests, use tarpaulin 2020-11-23 17:52:22 -05:00
Edward Shen 72ab5f29d8
Fix hot reloading with buffer swap editors
This required a fix with hotwatch to implement accepting an FnMut.
2020-11-22 14:49:51 -05:00
Edward Shen 2005c43066
Updated all dependices via cargo update 2020-11-03 20:20:40 -05:00
Edward Shen 6b51a81679
Alphabetize dependencies 2020-11-03 20:20:08 -05:00
Edward Shen a8f2e0cfd7
Use char instead of string for single char match 2020-11-03 20:18:51 -05:00
Edward Shen 60b7dc4219
update pkgbuild to 0.8.0 2020-09-28 01:02:32 -04:00
Edward Shen 75fb530ba0
bump to 0.8.0 2020-09-28 00:52:08 -04:00
Edward Shen ce21a63f16
Add JSON parsing from executables 2020-09-28 00:51:02 -04:00
Edward Shen b1cdce7c85
fix default config duplicate &ls 2020-09-27 21:52:06 -04:00
Edward Shen 83a2ba0f8a
Add config-parsing-time check for argument count range 2020-09-27 21:48:17 -04:00
Edward Shen 46261bdfa0
update default config file 2020-09-27 21:47:35 -04:00
Edward Shen 0df0c60013
add route min/max arg restrictions 2020-09-27 21:33:32 -04:00
Edward Shen 7585687710
Refactor route resolution 2020-09-27 19:37:24 -04:00
Edward Shen 7fdf451470
Use enum for route resolution 2020-09-27 17:02:43 -04:00
Edward Shen abbd1d9fea
use self closing tags in open search desc file 2020-09-27 16:17:10 -04:00
Edward Shen 5d7629487a
percent encode '&' and '%' 2020-09-27 16:09:46 -04:00
Edward Shen 630f7f803a
updated dependency versions 2020-09-27 15:31:08 -04:00
Edward Shen 50dce3a80b
Implement config size checks 2020-07-05 12:17:29 -04:00
Edward Shen 7995babb64
less unwrapping 2020-07-05 01:51:24 -04:00
Edward Shen 39a1037b33
remove uncessary arc 2020-07-05 01:37:45 -04:00
Edward Shen 10731c22f4
remove yaml feature on clap, remove itertools 2020-07-05 00:54:38 -04:00
Edward Shen 50360005f6
update pkgbuild to 0.7.0 2020-07-05 00:43:51 -04:00
Edward Shen 529f6a1ade
bump to 0.7.0 2020-07-05 00:17:00 -04:00
Edward Shen 1311cfa532
update example default config file 2020-07-05 00:16:59 -04:00
Edward Shen 9df8ea3558
fixed tests 2020-07-05 00:16:59 -04:00
Edward Shen dab4d52f4d
general cleanup 2020-07-05 00:16:59 -04:00
Edward Shen c50b4493ab
fix footer not in body 2020-07-05 00:16:59 -04:00
Edward Shen 323fa6ba71
added route descriptions, hidden routes and groups 2020-07-05 00:16:59 -04:00
Edward Shen a8fba09955
remove lint ignore on BunBunError 2020-07-05 00:16:59 -04:00
Edward Shen ed462ba67e
better error messages 2020-07-05 00:16:59 -04:00
Edward Shen 4226b75f18
update clap to ^3.0.0, return exit code 1 on error 2020-07-05 00:16:59 -04:00
Edward Shen 7a5910ce26
add deny(missing_docs) 2020-07-05 00:16:58 -04:00
Edward Shen 7faf15889a
implement intelligent config location selection. 2020-07-05 00:16:58 -04:00
Edward Shen 633a152f89
fixed config arg not taking in value 2020-07-05 00:16:55 -04:00
Edward Shen 9c81ff46e4
fix mistaken reference to bunbun.toml 2020-07-05 00:16:52 -04:00
Edward Shen ae28ee8a54
update pkgbuild to 0.6.2 2020-07-05 00:15:57 -04:00
Edward Shen 0ffa1419bc
bump to 0.6.2 2020-04-17 13:44:22 -04:00
Edward Shen 73f25b9ae8
add link to source, put footer at bottom of page 2020-04-17 13:43:53 -04:00
Edward Shen 843efc2e62
update landing page to be more generic 2020-04-17 13:27:10 -04:00
Edward Shen 9cf01f5991
explicitly set content-type to text/html; charset=utf-8 2020-04-17 13:23:00 -04:00
Edward Shen 8cdd216b39
remove extraneous uses 2020-04-14 20:39:36 -04:00
Edward Shen d6b3d4e143
update cargo.lock 2020-04-14 20:36:35 -04:00
Edward Shen 153512a800
update pkgbuild 2020-04-14 20:35:27 -04:00
Edward Shen 2835b8f646
bump version to 0.6.1 2020-04-14 20:34:29 -04:00
Edward Shen f109734a32
add version number to botton of pages 2020-04-14 20:34:04 -04:00
Edward Shen 70c7747ca9
update cargo lock 2020-04-08 20:58:46 -04:00
Edward Shen 9cc2d5952e
update readme 2020-01-01 00:29:37 -05:00
Edward Shen ec04821462
hopefully fix github runner compat for tests 2020-01-01 00:20:29 -05:00
Edward Shen 4e1db78ed9
documented manual tests 2020-01-01 00:13:29 -05:00
Edward Shen fd941b014a
add tests for resolve_path 2020-01-01 00:08:47 -05:00
Edward Shen 961cbd721b
move route serialization to config.rs 2019-12-31 23:07:01 -05:00
Edward Shen 1d7d547475
reorder routes 2019-12-31 23:00:54 -05:00
Edward Shen b8be3d8d53
typo fixes 2019-12-31 22:44:16 -05:00
Edward Shen cc7ac3e617
bump pkgbuild to 0.6.0 2019-12-31 20:59:01 -05:00
Edward Shen a16e874830
add automatic packaging script 2019-12-31 20:58:42 -05:00
Edward Shen 68099c74fb
bump crate to 0.6.0 2019-12-31 20:26:06 -05:00
Edward Shen e619b2cffc
minor documentation fixes 2019-12-31 20:04:51 -05:00
Edward Shen 6e2deccf24
handle custom programs' exit codes 2019-12-31 19:42:53 -05:00
Edward Shen 71df3394ad
canonicalize relative paths 2019-12-31 19:23:51 -05:00
Edward Shen c990aef0e9
execute from local file if possible 2019-12-31 19:12:17 -05:00
Edward Shen 1385045013
use enum for routes 2019-12-31 17:36:21 -05:00
Edward Shen a4543c48ec
move config management to own file 2019-12-29 00:48:02 -05:00
Edward Shen cf85a6494a
exclude aux/ when packaging 2019-12-29 00:16:41 -05:00
Edward Shen 5a122371da
forbid unsafe code 2019-12-29 00:09:18 -05:00
Edward Shen 97f5fa1455
Added tests 2019-12-29 00:08:13 -05:00
Edward Shen 776d5ead9b
updated pkgbuild for 0.5.0 2019-12-27 00:55:35 -05:00
Edward Shen 887d5bf3c2
bump version to 0.5.0 2019-12-27 00:37:21 -05:00
Edward Shen 6e9233c21d
add pkgbuild 2019-12-27 00:28:49 -05:00
Edward Shen 03918ea53e
remove libc and daemonize option 2019-12-27 00:02:12 -05:00
Edward Shen 4bbdb2f45f
add out folder to gitignore 2019-12-27 00:01:33 -05:00
Edward Shen 335baed266
bump to 0.4.1 2019-12-26 17:21:22 -05:00
Edward Shen b835e5b2e9
move error to own file 2019-12-26 17:01:45 -05:00
Edward Shen 0a8af55f05
define type for state data 2019-12-26 16:18:15 -05:00
Edward Shen 062c8f5a2e
move handlebar data out of state struct 2019-12-26 16:17:05 -05:00
Edward Shen b59cb4f9e3
move watch into own function 2019-12-26 15:49:42 -05:00
Edward Shen 983ad3733e
fix typo in prod release guide 2019-12-26 15:10:27 -05:00
Edward Shen 0a19214f36
update acitx to 2.0 2019-12-26 15:06:00 -05:00
Edward Shen fb48d77a10
add instructions to build for prod 2019-12-24 14:52:34 -05:00
Edward Shen 5bfcb972c5
add github workflow 2019-12-24 00:43:32 -05:00
Edward Shen 673061106a
bump to 0.4.0 2019-12-24 00:41:49 -05:00
Edward Shen e1d70e2c4f
update config file format 2019-12-24 00:05:56 -05:00
Edward Shen 9f4577f0ed
actually name yaml file correctly 2019-12-23 22:59:12 -05:00
Edward Shen f70154e819
use const over static 2019-12-23 22:21:42 -05:00
Edward Shen b09f1b96ea
better log level parsing 2019-12-23 22:13:38 -05:00
Edward Shen 5218c8a92d
smarter log matching 2019-12-23 19:42:30 -05:00
23 changed files with 2262 additions and 1869 deletions

15
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,15 @@
name: Rust
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
/target
**/*.rs.bk
bunbun.toml
out
tarpaulin-report.html

View File

@ -1,10 +1,15 @@
{
"cSpell.words": [
"Deserialization",
"Hotwatch",
"Serializer",
"actix",
"bunbun",
"bunbunsearch",
"canonicalize",
"itertools",
"opensearchdescription"
]
"opensearchdescription",
"tempfile"
],
"python.pythonPath": "/usr/bin/python3"
}

2304
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,34 @@
[package]
name = "bunbun"
version = "0.3.0"
version = "0.8.1"
authors = ["Edward Shen <code@eddie.sh>"]
edition = "2018"
edition = "2021"
description = "Re-implementation of bunny1 in Rust"
license = "AGPL-3.0"
readme = "README.md"
repository = "https://github.com/edward-shen/bunbun"
exclude = ["/aux/"]
[dependencies]
actix-web = "1.0"
serde = "1.0"
serde_yaml = "0.8"
handlebars = "2.0"
anyhow = "1"
arc-swap = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum = "0.6"
clap = { version = "4", features = ["wrap_help", "derive", "cargo"] }
dirs = "4"
handlebars = "4"
hotwatch = "0.4"
percent-encoding = "2.1"
itertools = "0.8"
libc = "0.2"
log = "0.4"
simple_logger = "1.3"
clap = { version = "2.33", features = ["yaml"] }
percent-encoding = "2"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tempfile = "3"
[profile.release]
lto = true
codegen-units = 1
strip = true

39
PKGBUILD Normal file
View File

@ -0,0 +1,39 @@
# Maintainer: Edward Shen <code@eddie.sh>
#
# You should _always_ use the latest PKGBUILD from master, as each releases
# PKGBUILD will contain the previous release's PKGBUILD. This is because one
# cannot generate the sha512sum of the release until it's been created, and this
# file would be part of said release.
pkgname=bunbun
pkgver=0.8.0
pkgrel=1
depends=('gcc-libs')
makedepends=('rust' 'cargo')
arch=('i686' 'x86_64' 'armv6h' 'armv7h')
pkgdesc="Re-implementation of bunny1 in Rust"
url="https://github.com/edward-shen/bunbun"
license=('AGPL')
source=("$pkgname-$pkgver.tar.gz::https://github.com/edward-shen/$pkgname/archive/$pkgver.tar.gz")
sha512sums=('55ecc42176e57863c87d7196e41f4971694eda7d74200214e2a64b6bb3b54c5990ab224301253317e38b079842318315891159113b6de754cd91171c808660bb')
build() {
cd "$pkgname-$pkgver"
cargo build --release --locked
strip "target/release/$pkgname" || true
}
check() {
cd "$pkgname-$pkgver"
cargo test --release --locked
}
package() {
cd "$pkgname-$pkgver"
install -Dm755 "target/release/$pkgname" -t "$pkgdir/usr/bin"
install -Dm644 "aux/systemd/$pkgname.service" -t "$pkgdir/usr/lib/systemd/system"
install -Dm644 "$pkgname.default.yaml" "$pkgdir/etc/$pkgname.yaml"
}

View File

@ -3,7 +3,7 @@
_Self-hostable, easy-to-configure, fast search/jump multiplexer service._
bunbun is a pure-[Rust][rust-lang] implementation of [bunny1][bunny1], providing
a customizable search engine and quick-jump tool in one.
a customizable search engine and quick-jump tool in one small binary.
After adding it to your web-browser and setting it as your default search
engine, you'll gain the ability to quick-jump to a specific page or search from
@ -18,12 +18,44 @@ foo bar // If foo is a defined command, do something with bar
// query for the default route
```
## Reasons to use bunbun
- Convenient: bunbun watches for config changes and refreshes its routes
automatically, allowing for rapid development.
- Extensible: supports simple route substitution or execution of arbitrary
programs for complex route resolution.
- Portable: bunbun runs off a single binary and config file.
- Small: binary is 1.3MB (after running `strip` and `upx --lzma` on the release
binary).
- Memory-safe: Built with [Rust][rust-lang].
## Installation
If you have `cargo`, you can simply run `cargo install bunbun`.
Once installed, simply run it. A default config file will be created if one does
not exist. You should model your own custom routes after the provided ones.
not exist.
If you're looking to run this as a daemon (as most would do), you should put the
binary in `/usr/bin` and copy `aux/systemd/bunbun.service` into your preferred
systemd system folder. Then you may run `systemctl enable bunbun --now` to start
a daemon instance of bunbun.
If running Arch Linux, you may use the provided `PKGBUILD` to install bunbun.
Run `makepkg` followed by `sudo pacman -U bunbun.<version>.tar.gz`. This
installs the systemd service for you. Run `systemctl enable bunbun --now` to
start bunbun as a daemon.
### Building for production
If you're looking to build a release binary, here are the steps I use:
1. `cargo build --release`
2. `upx --lzma target/release/bunbun`
LZMA provides the best level of compress for Rust binaries; it performs at the
same level as `upx --ultra-brute` without the time cost and [without breaking
the binary](https://github.com/upx/upx/issues/224).
### Configuration
@ -31,7 +63,7 @@ If configuring for development, no further configuration is required. If running
this for production, you should edit the `public_address` field.
the config file is watched, so updates are immediate unless invalid, or if
you're using certain programs such as nvim, which performs updating a file via
you're using certain programs such as `nvim`, which performs updating a file via
swapping rather than directly updating the file.
### Adding bunbun as a search engine

View File

@ -0,0 +1,13 @@
[Unit]
Description=Bunbun search multiplexer/jump service
After=network.target
StartLimitIntervalSec=0
[Service]
Type=simple
Restart=always
RestartSec=1
ExecStart=/usr/bin/bunbun
[Install]
WantedBy=multi-user.target

10
aux/update_pkgbuild Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p out
# shellcheck disable=SC2207
SHASUM=($(curl -sL "https://github.com/edward-shen/bunbun/archive/$1.tar.gz" | sha512sum))
HASH="${SHASUM[0]}"
sed -i "s/^pkgver=.*$/pkgver=$1/; s/sha512sums=('\w*/sha512sums=('$HASH/" PKGBUILD

View File

@ -1,20 +0,0 @@
# The location which your server is listening on and binds to. You must restart
# bunbun for changes to take effect for this config option.
bind_address: "127.0.0.1:8080"
# The root location where people can access your instance of bunbun
public_address: "localhost:8080"
# A default route, if no route is was matched. If none were matched, the entire
# query is used as the query for the default route. This field is optional.
default_route: "g"
routes:
# Meta
ls: "/ls"
help: "/ls"
list: "/ls"
# Google
g: "https://google.com/search?q={{query}}"
yt: "https://www.youtube.com/results?search_query={{query}}"
r: "https://reddit.com/r/{{query}}"

80
bunbun.default.yaml Normal file
View File

@ -0,0 +1,80 @@
# The location which your server is listening on and binds to. You must restart
# bunbun for changes to take effect for this config option.
bind_address: "127.0.0.1:8080"
# The root location where people can access your instance of bunbun
public_address: "localhost:8080"
# A default route, if no route is was matched. If none were matched, the entire
# query is used as the query for the default route. This field is optional, but
# highly recommended for ease-of-use.
default_route: "g"
# A list containing route groups. Each route group must have a name and a
# mapping of routes, with an optional description field. Each route mapping may
# contain "{{query}}", which will be populated by the user's search query. This
# input is percent-escaped. If multiple routes are defined, then the later
# defined route is used.
#
# You may provide an (absolute, recommended) path to an executable file to out-
# source route resolution to a program. The program will receive the arguments
# as space-separated words, without any shell parsing.
#
# These programs must return a JSON object with either one of the following
# key-value pairs:
# - "redirect": "some-path-to-redirect-to.com"
# - "body": The actual body to return.
# For example, to return a page that only prints out `3`, the function should
# return `{"redirect": "3"}`.
#
# These programs must be developed defensively, as they accept arbitrary user
# input. Improper handling of user input can easily lead to anywhere from simple
# flakey responses to remote code execution.
groups:
-
# This is a group with the name "Meta commands" with a short description.
name: "Meta commands"
description: "Commands for bunbun"
routes:
# /ls is the only page that comes with bunbun besides the homepage. This
# page provides a full list of routes and their groups they're in.
ls: &ls
path: "/ls"
# You can specify a maximum number of arguments, which are string
# delimited strings.
max_args: 0
# You can also specify a minimum amount of arguments.
# min_args: 1
help:
path: "/ls"
max_args: 0
# Paths can be hidden from the listings page if desired.
hidden: true
# Bunbun supports all standard YAML features, so things like YAML pointers
# and references are supported.
list: *ls
-
# This is another group without a description
name: "Google"
routes:
# Routes can be quickly defined as a simple link, where {{query}} is where
# your query to bunbun is forwarded to.
g: "https://google.com/search?q={{query}}"
# Alternatively, you can provide a description instead, which provides
# replaces the raw query string on the ls page with said description
yt:
path: "https://www.youtube.com/results?search_query={{query}}"
description: "A way to quickly search youtube videos"
-
name: "Uncategorized routes"
routes:
r: "https://reddit.com/r/{{query}}"
# Routes don't need the {{query}} tag, so links can just be shortcuts to
# pages you'd like
nice: "https://youtu.be/dQw4w9WgXcQ"
-
# This group is entirely hidden, so all routes under it are hidden.
name: "Hidden group"
hidden: true
routes:
sneaky: "https://nyan.cat"

11
code_coverage Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euxo pipefail
if ! cargo tarpaulin -h &> /dev/null; then
echo "Tarpaulin not installed, automatically installing in 3 seconds.";
sleep 3;
cargo install cargo-tarpaulin;
fi;
cargo tarpaulin -o html && xdg-open "tarpaulin-report.html"

View File

@ -1,3 +0,0 @@
tab_spaces = 2
use_field_init_shorthand = true
max_width = 80

17
src/cli.rs Normal file
View File

@ -0,0 +1,17 @@
use clap::{crate_authors, crate_version, Parser};
use std::path::PathBuf;
use tracing_subscriber::filter::Directive;
#[derive(Parser)]
#[clap(version = crate_version!(), author = crate_authors!())]
pub struct Opts {
/// Set the logging directives
#[clap(long, default_value = "info")]
pub log: Vec<Directive>,
/// Specify the location of the config file to read from. Needs read/write permissions.
#[clap(short, long)]
pub config: Option<PathBuf>,
/// Allow config sizes larger than 100MB.
#[clap(long)]
pub large_config: bool,
}

View File

@ -1,25 +0,0 @@
name: "bunbun"
about: "Search/jump multiplexer service"
args:
- verbose:
short: "v"
long: "verbose"
multiple: true
help: Increases the log level to info, debug, and trace, respectively.
conflicts_with: "quiet"
- quiet:
short: "q"
long: "quiet"
multiple: true
help: Decreases the log level to error or no logging at all, respectively.
conflicts_with: "verbose"
- daemon:
short: "d"
long: "daemon"
help: "Run bunbun as a daemon."
- config:
short: "c"
long: "config"
default_value: "/etc/bunbun.toml"
help: Specify the location of the config file to read from. Needs read/write permissions.

426
src/config.rs Normal file
View File

@ -0,0 +1,426 @@
use crate::BunBunError;
use dirs::{config_dir, home_dir};
use serde::{
de::{self, Deserializer, MapAccess, Unexpected, Visitor},
Deserialize, Serialize,
};
use std::collections::HashMap;
use std::fmt;
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::PathBuf;
use tracing::{debug, info, trace};
const CONFIG_FILENAME: &str = "bunbun.yaml";
const DEFAULT_CONFIG: &[u8] = include_bytes!("../bunbun.default.yaml");
#[cfg(not(test))]
const LARGE_FILE_SIZE_THRESHOLD: u64 = 100_000_000;
#[cfg(test)]
const LARGE_FILE_SIZE_THRESHOLD: u64 = 1_000_000;
#[derive(Deserialize, Debug, PartialEq, Eq)]
pub struct Config {
pub bind_address: String,
pub public_address: String,
pub default_route: Option<String>,
pub groups: Vec<RouteGroup>,
}
#[derive(Deserialize, Serialize, Debug, Eq, PartialEq, Clone)]
pub struct RouteGroup {
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub hidden: bool,
pub routes: HashMap<String, Route>,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
pub struct Route {
pub route_type: RouteType,
pub path: String,
pub hidden: bool,
pub description: Option<String>,
pub min_args: Option<usize>,
pub max_args: Option<usize>,
}
impl From<String> for Route {
fn from(s: String) -> Self {
Self {
route_type: get_route_type(&s),
path: s,
hidden: false,
description: None,
min_args: None,
max_args: None,
}
}
}
impl From<&'static str> for Route {
fn from(s: &'static str) -> Self {
Self {
route_type: get_route_type(s),
path: s.to_string(),
hidden: false,
description: None,
min_args: None,
max_args: None,
}
}
}
/// Deserialization of the route string into the enum requires us to figure out
/// whether or not the string is valid to run as an executable or not. To
/// determine this, we simply check if it exists on disk or assume that it's a
/// web path. This incurs a disk check operation, but since users shouldn't be
/// updating the config that frequently, it should be fine.
impl<'de> Deserialize<'de> for Route {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(field_identifier, rename_all = "snake_case")]
enum Field {
Path,
Hidden,
Description,
MinArgs,
MaxArgs,
}
struct RouteVisitor;
impl<'de> Visitor<'de> for RouteVisitor {
type Value = Route;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("string")
}
fn visit_str<E>(self, path: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(Self::Value::from(path.to_owned()))
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut path = None;
let mut hidden = None;
let mut description = None;
let mut min_args = None;
let mut max_args = None;
while let Some(key) = map.next_key()? {
match key {
Field::Path => {
if path.is_some() {
return Err(de::Error::duplicate_field("path"));
}
path = Some(map.next_value::<String>()?);
}
Field::Hidden => {
if hidden.is_some() {
return Err(de::Error::duplicate_field("hidden"));
}
hidden = map.next_value()?;
}
Field::Description => {
if description.is_some() {
return Err(de::Error::duplicate_field("description"));
}
description = Some(map.next_value()?);
}
Field::MinArgs => {
if min_args.is_some() {
return Err(de::Error::duplicate_field("min_args"));
}
min_args = Some(map.next_value()?);
}
Field::MaxArgs => {
if max_args.is_some() {
return Err(de::Error::duplicate_field("max_args"));
}
max_args = Some(map.next_value()?);
}
}
}
if let (Some(min_args), Some(max_args)) = (min_args, max_args) {
if min_args > max_args {
{
return Err(de::Error::invalid_value(
Unexpected::Other(&format!(
"argument count range {min_args} to {max_args}",
)),
&"a valid argument count range",
));
}
}
}
let path = path.ok_or_else(|| de::Error::missing_field("path"))?;
Ok(Route {
route_type: get_route_type(&path),
path,
hidden: hidden.unwrap_or_default(),
description,
min_args,
max_args,
})
}
}
deserializer.deserialize_any(RouteVisitor)
}
}
impl std::fmt::Display for Route {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self {
route_type: RouteType::External,
path,
..
} => write!(f, "raw ({path})"),
Self {
route_type: RouteType::Internal,
path,
..
} => write!(f, "file ({path})"),
}
}
}
/// Classifies the path depending on if the there exists a local file.
fn get_route_type(path: &str) -> RouteType {
if std::path::Path::new(path).exists() {
debug!("Parsed {path} as a valid local path.");
RouteType::Internal
} else {
debug!("{path} does not exist on disk, assuming web path.");
RouteType::External
}
}
/// There exists two route types: an external path (e.g. a URL) or an internal
/// path (to a file).
#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
pub enum RouteType {
External,
Internal,
}
pub struct FileData {
pub path: PathBuf,
pub file: File,
}
/// If a provided config path isn't found, this function checks known good
/// locations for a place to write a config file to. In order, it checks the
/// system-wide config location (`/etc/`, in Linux), followed by the config
/// folder, followed by the user's home folder.
pub fn get_config_data() -> Result<FileData, BunBunError> {
// Locations to check, with highest priority first
let locations: Vec<_> = {
let mut folders = vec![PathBuf::from("/etc/")];
// Config folder
if let Some(folder) = config_dir() {
folders.push(folder);
}
// Home folder
if let Some(folder) = home_dir() {
folders.push(folder);
}
folders
.iter_mut()
.for_each(|folder| folder.push(CONFIG_FILENAME));
folders
};
debug!("Checking locations for config file: {:?}", &locations);
for location in &locations {
let file = OpenOptions::new().read(true).open(location);
match file {
Ok(file) => {
debug!("Found file at {location:?}.");
return Ok(FileData {
path: location.clone(),
file,
});
}
Err(e) => {
debug!("Tried to read '{location:?}' but failed due to error: {e}");
}
}
}
debug!("Failed to find any config. Now trying to find first writable path");
// If we got here, we failed to read any file paths, meaning no config exists
// yet. In that case, try to return the first location that we can write to,
// after writing the default config
for location in locations {
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(location.clone());
match file {
Ok(mut file) => {
info!("Creating new config file at {location:?}.");
file.write_all(DEFAULT_CONFIG)?;
let file = OpenOptions::new().read(true).open(location.clone())?;
return Ok(FileData {
path: location,
file,
});
}
Err(e) => {
debug!("Tried to open a new file at '{location:?}' but failed due to error: {e}");
}
}
}
Err(BunBunError::NoValidConfigPath)
}
/// Assumes that the user knows what they're talking about and will only try
/// to load the config at the given path.
pub fn load_custom_file(path: impl Into<PathBuf>) -> Result<FileData, BunBunError> {
let path = path.into();
let file = OpenOptions::new()
.read(true)
.open(&path)
.map_err(|e| BunBunError::InvalidConfigPath(path.clone(), e))?;
Ok(FileData { path, file })
}
pub fn load_file(mut config_file: File, large_config: bool) -> Result<Config, BunBunError> {
trace!("Loading config file.");
let file_size = config_file.metadata()?.len();
// 100 MB
if file_size > LARGE_FILE_SIZE_THRESHOLD && !large_config {
return Err(BunBunError::ConfigTooLarge(file_size));
}
if file_size == 0 {
return Err(BunBunError::ZeroByteConfig);
}
let mut config_data = String::new();
config_file.read_to_string(&mut config_data)?;
// Reading from memory is faster than reading directly from a reader for some
// reason; see https://github.com/serde-rs/json/issues/160
Ok(serde_yaml::from_str(&config_data)?)
}
#[cfg(test)]
mod route {
use super::*;
use anyhow::{Context, Result};
use serde_yaml::{from_str, to_string};
use std::path::Path;
use tempfile::NamedTempFile;
#[test]
fn deserialize_relative_path() -> Result<()> {
let tmpfile = NamedTempFile::new_in(".")?;
let path = tmpfile.path().display().to_string();
let path = path
.get(path.rfind(".").context("While finding .")?..)
.context("While getting the path")?;
let path = Path::new(path);
assert!(path.is_relative());
let path = path.to_str().context("While stringifying path")?;
assert_eq!(from_str::<Route>(path)?, Route::from(path.to_owned()));
Ok(())
}
#[test]
fn deserialize_absolute_path() -> Result<()> {
let tmpfile = NamedTempFile::new()?;
let path = format!("{}", tmpfile.path().display());
assert!(tmpfile.path().is_absolute());
assert_eq!(from_str::<Route>(&path)?, Route::from(path));
Ok(())
}
#[test]
fn deserialize_http_path() -> Result<()> {
assert_eq!(
from_str::<Route>("http://google.com")?,
Route::from("http://google.com")
);
Ok(())
}
#[test]
fn deserialize_https_path() -> Result<()> {
assert_eq!(
from_str::<Route>("https://google.com")?,
Route::from("https://google.com")
);
Ok(())
}
#[test]
fn serialize() -> Result<()> {
assert_eq!(
&to_string(&Route::from("hello world"))?,
"---\nroute_type: External\npath: hello world\nhidden: false\ndescription: ~\nmin_args: ~\nmax_args: ~\n"
);
Ok(())
}
}
#[cfg(test)]
mod read_config {
use super::*;
use anyhow::Result;
#[test]
fn empty_file() -> Result<()> {
let config_file = tempfile::tempfile()?;
assert!(matches!(
load_file(config_file, false),
Err(BunBunError::ZeroByteConfig)
));
Ok(())
}
#[test]
fn config_too_large() -> Result<()> {
let mut config_file = tempfile::tempfile()?;
let size_to_write = (LARGE_FILE_SIZE_THRESHOLD + 1) as usize;
config_file.write(&[0].repeat(size_to_write))?;
match load_file(config_file, false) {
Err(BunBunError::ConfigTooLarge(size)) if size as usize == size_to_write => {}
Err(BunBunError::ConfigTooLarge(size)) => {
panic!("Mismatched size: {size} != {size_to_write}")
}
res => panic!("Wrong result, got {res:#?}"),
}
Ok(())
}
#[test]
fn valid_config() -> Result<()> {
assert!(load_file(File::open("bunbun.default.yaml")?, false).is_ok());
Ok(())
}
}

53
src/error.rs Normal file
View File

@ -0,0 +1,53 @@
use std::error::Error;
use std::fmt;
#[derive(Debug)]
#[allow(clippy::module_name_repetitions)]
pub enum BunBunError {
Io(std::io::Error),
Parse(serde_yaml::Error),
Watch(hotwatch::Error),
CustomProgram(String),
NoValidConfigPath,
InvalidConfigPath(std::path::PathBuf, std::io::Error),
ConfigTooLarge(u64),
ZeroByteConfig,
JsonParse(serde_json::Error),
}
impl Error for BunBunError {}
impl fmt::Display for BunBunError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Io(e) => e.fmt(f),
Self::Parse(e) => e.fmt(f),
Self::Watch(e) => e.fmt(f),
Self::CustomProgram(msg) => msg.fmt(f),
Self::NoValidConfigPath => write!(f, "No valid config path was found!"),
Self::InvalidConfigPath(path, reason) => {
write!(f, "Failed to access {path:?}: {reason}")
}
Self::ConfigTooLarge(size) => write!(f, "The config file was too large ({size} bytes)! Pass in --large-config to bypass this check."),
Self::ZeroByteConfig => write!(f, "The config provided reported a size of 0 bytes. Please check your config path!"),
Self::JsonParse(e) => e.fmt(f),
}
}
}
/// Generates a from implementation from the specified type to the provided
/// bunbun error.
macro_rules! from_error {
($from:ty, $to:ident) => {
impl From<$from> for BunBunError {
fn from(e: $from) -> Self {
Self::$to(e)
}
}
};
}
from_error!(std::io::Error, Io);
from_error!(serde_yaml::Error, Parse);
from_error!(hotwatch::Error, Watch);
from_error!(serde_json::Error, JsonParse);

View File

@ -1,210 +1,121 @@
use actix_web::middleware::Logger;
use actix_web::{App, HttpServer};
use clap::{crate_authors, crate_version, load_yaml, App as ClapApp};
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![warn(clippy::nursery, clippy::pedantic)]
//! Bunbun is a pure-Rust implementation of bunny1 that provides a customizable
//! search engine and quick-jump tool in one small binary. For information on
//! usage, please take a look at the readme.
use crate::config::{get_config_data, load_custom_file, load_file, FileData, Route, RouteGroup};
use anyhow::Result;
use arc_swap::ArcSwap;
use axum::routing::get;
use axum::{Extension, Router};
use clap::Parser;
use error::BunBunError;
use handlebars::Handlebars;
use hotwatch::{Event, Hotwatch};
use libc::daemon;
use log::{debug, error, info, trace, warn};
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt;
use std::fs::{read_to_string, OpenOptions};
use std::io::Write;
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, trace, warn};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
mod cli;
mod config;
#[cfg(not(tarpaulin_include))]
mod error;
mod routes;
#[cfg(not(tarpaulin_include))]
mod template_args;
static DEFAULT_CONFIG: &[u8] = include_bytes!("../bunbun.default.toml");
#[derive(Debug)]
#[allow(clippy::enum_variant_names)]
enum BunBunError {
IoError(std::io::Error),
ParseError(serde_yaml::Error),
WatchError(hotwatch::Error),
LoggerInitError(log::SetLoggerError),
}
impl fmt::Display for BunBunError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BunBunError::IoError(e) => e.fmt(f),
BunBunError::ParseError(e) => e.fmt(f),
BunBunError::WatchError(e) => e.fmt(f),
BunBunError::LoggerInitError(e) => e.fmt(f),
}
}
}
/// Generates a from implementation from the specified type to the provided
/// bunbun error.
macro_rules! from_error {
($from:ty, $to:ident) => {
impl From<$from> for BunBunError {
fn from(e: $from) -> Self {
BunBunError::$to(e)
}
}
};
}
from_error!(std::io::Error, IoError);
from_error!(serde_yaml::Error, ParseError);
from_error!(hotwatch::Error, WatchError);
from_error!(log::SetLoggerError, LoggerInitError);
/// Dynamic variables that either need to be present at runtime, or can be
/// changed during runtime.
pub struct State {
public_address: String,
default_route: Option<String>,
routes: HashMap<String, String>,
renderer: Handlebars,
public_address: String,
default_route: Option<String>,
groups: Vec<RouteGroup>,
/// Cached, flattened mapping of all routes and their destinations.
routes: HashMap<String, Route>,
}
fn main() -> Result<(), BunBunError> {
let yaml = load_yaml!("cli.yaml");
let matches = ClapApp::from(yaml)
.version(crate_version!())
.author(crate_authors!())
.get_matches();
#[tokio::main]
#[cfg(not(tarpaulin_include))]
async fn main() -> Result<()> {
use tracing_subscriber::EnvFilter;
let log_level = match (
matches.occurrences_of("quiet"),
matches.occurrences_of("verbose"),
) {
(2..=std::u64::MAX, _) => None,
(1, _) => Some(log::Level::Error),
(0, 0) => Some(log::Level::Warn),
(_, 1) => Some(log::Level::Info),
(_, 2) => Some(log::Level::Debug),
(_, 3..=std::u64::MAX) => Some(log::Level::Trace),
};
let opts = cli::Opts::parse();
if let Some(level) = log_level {
simple_logger::init_with_level(level)?;
}
// config has default location provided
let conf_file_location = String::from(matches.value_of("config").unwrap());
let conf = read_config(&conf_file_location)?;
let renderer = compile_templates();
let state = Arc::from(RwLock::new(State {
public_address: conf.public_address,
default_route: conf.default_route,
routes: conf.routes,
renderer,
}));
// Daemonize after trying to read from config and before watching; allow user
// to see a bad config (daemon process sets std{in,out} to /dev/null)
if matches.is_present("daemon") {
unsafe {
debug!("Daemon flag provided. Running as a daemon.");
daemon(0, 0);
let mut env_filter = EnvFilter::from_default_env();
for directive in opts.log {
env_filter = env_filter.add_directive(directive);
}
}
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?;
// TODO: keep retry watching in separate thread
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(env_filter)
.init();
// Closures need their own copy of variables for proper lifecycle management
let state_ref = state.clone();
let conf_file_location_clone = conf_file_location.clone();
let watch_result = watch.watch(&conf_file_location, move |e: Event| {
if let Event::Write(_) = e {
trace!("Grabbing writer lock on state...");
let mut state = state.write().unwrap();
trace!("Obtained writer lock on state!");
match read_config(&conf_file_location_clone) {
Ok(conf) => {
state.public_address = conf.public_address;
state.default_route = conf.default_route;
state.routes = conf.routes;
info!("Successfully updated active state");
let conf_data = opts.config.map_or_else(get_config_data, load_custom_file)?;
let conf = load_file(conf_data.file.try_clone()?, opts.large_config)?;
let state = Arc::from(ArcSwap::from_pointee(State {
public_address: conf.public_address,
default_route: conf.default_route,
routes: cache_routes(conf.groups.clone()),
groups: conf.groups,
}));
// Cannot be named _ or Rust will immediately drop it.
let _watch = start_watch(Arc::clone(&state), conf_data, opts.large_config);
let app = Router::new()
.route("/", get(routes::index))
.route("/bunbunsearch.xml", get(routes::opensearch))
.route("/ls", get(routes::list))
.route("/hop", get(routes::hop))
.layer(Extension(compile_templates()?))
.layer(Extension(state));
let bind_addr = conf.bind_address.parse()?;
info!("Starting server at {bind_addr}");
axum::Server::bind(&bind_addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
/// Generates a hashmap of routes from the data structure created by the config
/// file. This should improve runtime performance and is a better solution than
/// just iterating over the config object for every hop resolution.
fn cache_routes(groups: Vec<RouteGroup>) -> HashMap<String, Route> {
let mut mapping = HashMap::new();
for group in groups {
for (kw, dest) in group.routes {
// This function isn't called often enough to not be a performance issue.
if let Some(old_value) = mapping.insert(kw.clone(), dest.clone()) {
trace!("Overriding {kw} route from {old_value} to {dest}.");
} else {
trace!("Inserting {kw} into mapping.");
}
}
Err(e) => warn!("Failed to update config file: {}", e),
}
} else {
debug!("Saw event {:#?} but ignored it", e);
}
});
match watch_result {
Ok(_) => info!("Watcher is now watching {}", &conf_file_location),
Err(e) => warn!(
"Couldn't watch {}: {}. Changes to this file won't be seen!",
&conf_file_location, e
),
}
HttpServer::new(move || {
App::new()
.data(state_ref.clone())
.wrap(Logger::default())
.service(routes::hop)
.service(routes::list)
.service(routes::index)
.service(routes::opensearch)
})
.bind(&conf.bind_address)?
.run()?;
Ok(())
}
#[derive(Deserialize)]
struct Config {
bind_address: String,
public_address: String,
default_route: Option<String>,
routes: HashMap<String, String>,
}
/// Attempts to read the config file. If it doesn't exist, generate one a
/// default config file before attempting to parse it.
fn read_config(config_file_path: &str) -> Result<Config, BunBunError> {
trace!("Loading config file...");
let config_str = match read_to_string(config_file_path) {
Ok(conf_str) => {
debug!("Successfully loaded config file into memory.");
conf_str
}
Err(_) => {
info!(
"Unable to find a {} file. Creating default!",
config_file_path
);
let fd = OpenOptions::new()
.write(true)
.create_new(true)
.open(config_file_path);
match fd {
Ok(mut fd) => fd.write_all(DEFAULT_CONFIG)?,
Err(e) => {
error!("Failed to write to {}: {}. Default config will be loaded but not saved.", config_file_path, e);
}
};
String::from_utf8_lossy(DEFAULT_CONFIG).into_owned()
}
};
// Reading from memory is faster than reading directly from a reader for some
// reason; see https://github.com/serde-rs/json/issues/160
Ok(serde_yaml::from_str(&config_str)?)
mapping
}
/// Returns an instance with all pre-generated templates included into the
/// binary. This allows for users to have a portable binary without needed the
/// templates at runtime.
fn compile_templates() -> Handlebars {
let mut handlebars = Handlebars::new();
macro_rules! register_template {
fn compile_templates() -> Result<Handlebars<'static>> {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
handlebars.register_partial("bunbun_version", env!("CARGO_PKG_VERSION"))?;
handlebars.register_partial("bunbun_src", env!("CARGO_PKG_REPOSITORY"))?;
macro_rules! register_template {
[ $( $template:expr ),* ] => {
$(
handlebars
@ -212,12 +123,157 @@ fn compile_templates() -> Handlebars {
$template,
String::from_utf8_lossy(
include_bytes!(concat!("templates/", $template, ".hbs")))
)
.unwrap();
)?;
debug!("Loaded {} template.", $template);
)*
};
}
register_template!["index", "list", "opensearch"];
handlebars
register_template!["index", "list", "opensearch"];
Ok(handlebars)
}
/// Starts the watch on a file, if possible. This will only return an Error if
/// the notify library (used by Hotwatch) fails to initialize, which is
/// considered to be a more serve error as it may be indicative of a low-level
/// problem. If a watch was unsuccessfully obtained (the most common is due to
/// the file not existing), then this will simply warn before returning a watch
/// object.
///
/// This watch object should be kept in scope as dropping it releases all
/// watches.
#[cfg(not(tarpaulin_include))]
fn start_watch(
state: Arc<ArcSwap<State>>,
config_data: FileData,
large_config: bool,
) -> Result<Hotwatch> {
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?;
let FileData { path, mut file } = config_data;
let watch_result = watch.watch(&path, move |e: Event| {
if let Event::Create(ref path) = e {
file = load_custom_file(path).expect("file to exist at path").file;
trace!("Getting new file handler as file was recreated.");
}
match e {
Event::Write(_) | Event::Create(_) => {
trace!("Grabbing writer lock on state...");
trace!("Obtained writer lock on state!");
match load_file(
file.try_clone().expect("Failed to clone file handle"),
large_config,
) {
Ok(conf) => {
state.store(Arc::new(State {
public_address: conf.public_address,
default_route: conf.default_route,
routes: cache_routes(conf.groups.clone()),
groups: conf.groups,
}));
info!("Successfully updated active state");
}
Err(e) => warn!("Failed to update config file: {e}"),
}
}
_ => debug!("Saw event {e:#?} but ignored it"),
}
});
match watch_result {
Ok(_) => info!("Watcher is now watching {path:?}"),
Err(e) => {
warn!("Couldn't watch {path:?}: {e}. Changes to this file won't be seen!");
}
}
Ok(watch)
}
#[cfg(test)]
mod cache_routes {
use super::*;
use std::iter::FromIterator;
fn generate_external_routes(routes: &[(&'static str, &'static str)]) -> HashMap<String, Route> {
HashMap::from_iter(
routes
.into_iter()
.map(|(key, value)| ((*key).to_owned(), Route::from(*value))),
)
}
#[test]
fn empty_groups_yield_empty_routes() {
assert_eq!(cache_routes(Vec::new()), HashMap::new());
}
#[test]
fn disjoint_groups_yield_summed_routes() {
let group1 = RouteGroup {
name: String::from("x"),
description: Some(String::from("y")),
routes: generate_external_routes(&[("a", "b"), ("c", "d")]),
hidden: false,
};
let group2 = RouteGroup {
name: String::from("5"),
description: Some(String::from("6")),
routes: generate_external_routes(&[("1", "2"), ("3", "4")]),
hidden: false,
};
assert_eq!(
cache_routes(vec![group1, group2]),
generate_external_routes(&[("a", "b"), ("c", "d"), ("1", "2"), ("3", "4")])
);
}
#[test]
fn overlapping_groups_use_latter_routes() {
let group1 = RouteGroup {
name: String::from("x"),
description: Some(String::from("y")),
routes: generate_external_routes(&[("a", "b"), ("c", "d")]),
hidden: false,
};
let group2 = RouteGroup {
name: String::from("5"),
description: Some(String::from("6")),
routes: generate_external_routes(&[("a", "1"), ("c", "2")]),
hidden: false,
};
assert_eq!(
cache_routes(vec![group1.clone(), group2]),
generate_external_routes(&[("a", "1"), ("c", "2")])
);
let group3 = RouteGroup {
name: String::from("5"),
description: Some(String::from("6")),
routes: generate_external_routes(&[("a", "1"), ("b", "2")]),
hidden: false,
};
assert_eq!(
cache_routes(vec![group1, group3]),
generate_external_routes(&[("a", "1"), ("b", "2"), ("c", "d")])
);
}
}
#[cfg(test)]
mod compile_templates {
use super::compile_templates;
/// Successful compilation of the binary guarantees that the templates will be
/// present to be registered to. Thus, we only really need to see that
/// compilation of the templates don't panic, which is just making sure that
/// the function can be successfully called.
#[test]
fn templates_compile() {
let _ = compile_templates();
}
}

View File

@ -1,60 +1,144 @@
use crate::template_args;
use crate::State;
use actix_web::get;
use actix_web::http::header;
use actix_web::web::{Data, Query};
use actix_web::{HttpResponse, Responder};
use itertools::Itertools;
use log::debug;
use crate::config::{Route as ConfigRoute, RouteType};
use crate::{template_args, BunBunError, Route, State};
use arc_swap::ArcSwap;
use axum::body::{boxed, Bytes, Empty, Full};
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Extension;
use handlebars::Handlebars;
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use serde::Deserialize;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use tracing::{debug, error};
/// https://url.spec.whatwg.org/#fragment-percent-encode-set
static FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'+');
// https://url.spec.whatwg.org/#fragment-percent-encode-set
const FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'+')
.add(b'&') // Interpreted as a GET query
.add(b'#') // Interpreted as a hyperlink section target
.add(b'\'');
#[get("/ls")]
pub fn list(data: Data<Arc<RwLock<State>>>) -> impl Responder {
let data = data.read().unwrap();
HttpResponse::Ok().body(data.renderer.render("list", &data.routes).unwrap())
#[allow(clippy::unused_async)]
pub async fn index(
Extension(data): Extension<Arc<ArcSwap<State>>>,
Extension(handlebars): Extension<Handlebars<'static>>,
) -> impl IntoResponse {
handlebars
.render(
"index",
&template_args::hostname(&data.load().public_address),
)
.map(Html)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[derive(Deserialize)]
#[allow(clippy::unused_async)]
pub async fn opensearch(
Extension(data): Extension<Arc<ArcSwap<State>>>,
Extension(handlebars): Extension<Handlebars<'static>>,
) -> impl IntoResponse {
handlebars
.render(
"opensearch",
&template_args::hostname(&data.load().public_address),
)
.map(|body| {
(
StatusCode::OK,
[(
header::CONTENT_TYPE,
"application/opensearchdescription+xml",
)],
body,
)
})
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[allow(clippy::unused_async)]
pub async fn list(
Extension(data): Extension<Arc<ArcSwap<State>>>,
Extension(handlebars): Extension<Handlebars<'static>>,
) -> impl IntoResponse {
handlebars
.render("list", &data.load().groups)
.map(Html)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[derive(Deserialize, Debug)]
pub struct SearchQuery {
to: String,
to: String,
}
#[get("/hop")]
pub fn hop(
data: Data<Arc<RwLock<State>>>,
query: Query<SearchQuery>,
) -> impl Responder {
let data = data.read().unwrap();
#[allow(clippy::unused_async)]
pub async fn hop(
Extension(data): Extension<Arc<ArcSwap<State>>>,
Extension(handlebars): Extension<Handlebars<'static>>,
Query(query): Query<SearchQuery>,
) -> impl IntoResponse {
let data = data.load();
match resolve_hop(&query.to, &data.routes, &data.default_route) {
(Some(path), args) => HttpResponse::Found()
.header(
header::LOCATION,
data
.renderer
.render_template(
&path,
&template_args::query(
utf8_percent_encode(&args, FRAGMENT_ENCODE_SET).to_string(),
),
)
.unwrap(),
)
.finish(),
(None, _) => HttpResponse::NotFound().body("not found"),
}
match resolve_hop(&query.to, &data.routes, data.default_route.as_deref()) {
RouteResolution::Resolved { route: path, args } => {
let resolved_template = match path {
ConfigRoute {
route_type: RouteType::Internal,
path,
..
} => resolve_path(Path::new(path), &args),
ConfigRoute {
route_type: RouteType::External,
path,
..
} => Ok(HopAction::Redirect(Cow::Borrowed(path))),
};
match resolved_template {
Ok(HopAction::Redirect(path)) => {
let rendered = handlebars
.render_template(
&path,
&template_args::query(utf8_percent_encode(&args, FRAGMENT_ENCODE_SET)),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, rendered)
.body(boxed(Empty::new()))
}
Ok(HopAction::Body(body)) => Response::builder()
.status(StatusCode::OK)
.body(boxed(Full::new(Bytes::from(body)))),
Err(e) => {
error!("Failed to redirect user for {path}: {e}");
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(boxed(Full::from("Something went wrong :(\n")))
}
}
}
RouteResolution::Unresolved => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(boxed(Full::from("not found\n"))),
}
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[derive(Debug, PartialEq)]
enum RouteResolution<'a> {
Resolved { route: &'a Route, args: String },
Unresolved,
}
/// Attempts to resolve the provided string into its route and its arguments.
@ -63,80 +147,265 @@ pub fn hop(
///
/// The first element in the tuple describes the route, while the second element
/// returns the remaining arguments. If none remain, an empty string is given.
fn resolve_hop(
query: &str,
routes: &HashMap<String, String>,
default_route: &Option<String>,
) -> (Option<String>, String) {
let mut split_args = query.split_ascii_whitespace().peekable();
let command = match split_args.peek() {
Some(command) => command,
None => {
debug!("Found empty query, returning no route.");
return (None, String::new());
}
};
fn resolve_hop<'a>(
query: &str,
routes: &'a HashMap<String, Route>,
default_route: Option<&str>,
) -> RouteResolution<'a> {
let mut split_args = query.split_ascii_whitespace().peekable();
let maybe_route = if let Some(command) = split_args.peek() {
routes.get(*command)
} else {
debug!("Found empty query, returning no route.");
return RouteResolution::Unresolved;
};
match (routes.get(*command), default_route) {
// Found a route
(Some(resolved), _) => (
Some(resolved.clone()),
match split_args.next() {
// Discard the first result, we found the route using the first arg
Some(_) => {
let args = split_args.join(" ");
debug!("Resolved {} with args {}", resolved, args);
args
let args = split_args.collect::<Vec<_>>();
let arg_count = args.len();
// Try resolving with a matched command
if let Some(route) = maybe_route {
let args = if args.is_empty() { &[] } else { &args[1..] }.join(" ");
let arg_count = arg_count - 1;
if check_route(route, arg_count) {
debug!("Resolved {route} with args {args}");
return RouteResolution::Resolved { route, args };
}
None => {
debug!("Resolved {} with no args", resolved);
String::new()
}
// Try resolving with the default route, if it exists
if let Some(route) = default_route.and_then(|route| routes.get(route)) {
if check_route(route, arg_count) {
let args = args.join(" ");
debug!("Using default route {route} with args {args}");
return RouteResolution::Resolved { route, args };
}
},
),
// Unable to find route, but had a default route
(None, Some(route)) => {
let args = split_args.join(" ");
debug!("Using default route {} with args {}", route, args);
(routes.get(route).cloned(), args)
}
// No default route and no match
(None, None) => {
debug!("Failed to resolve route!");
(None, String::new())
}
}
RouteResolution::Unresolved
}
#[get("/")]
pub fn index(data: Data<Arc<RwLock<State>>>) -> impl Responder {
let data = data.read().unwrap();
HttpResponse::Ok().body(
data
.renderer
.render(
"index",
&template_args::hostname(data.public_address.clone()),
)
.unwrap(),
)
/// Checks if the user provided string has the correct properties required by
/// the route to be successfully matched.
const fn check_route(route: &Route, arg_count: usize) -> bool {
if let Some(min_args) = route.min_args {
if arg_count < min_args {
return false;
}
}
if let Some(max_args) = route.max_args {
if arg_count > max_args {
return false;
}
}
true
}
#[get("/bunbunsearch.xml")]
pub fn opensearch(data: Data<Arc<RwLock<State>>>) -> impl Responder {
let data = data.read().unwrap();
HttpResponse::Ok()
.header(
header::CONTENT_TYPE,
"application/opensearchdescription+xml",
)
.body(
data
.renderer
.render(
"opensearch",
&template_args::hostname(data.public_address.clone()),
)
.unwrap(),
)
#[derive(Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum HopAction<'a> {
Redirect(Cow<'a, str>),
Body(String),
}
/// Runs the executable with the user's input as a single argument. Returns Ok
/// so long as the executable was successfully executed. Returns an Error if the
/// file doesn't exist or bunbun did not have permission to read and execute the
/// file.
fn resolve_path(path: &Path, args: &str) -> Result<HopAction<'static>, BunBunError> {
let output = Command::new(path.canonicalize()?)
.args(args.split(' '))
.output()?;
if output.status.success() {
Ok(serde_json::from_slice(&output.stdout)?)
} else {
error!(
"Program exit code for {} was not 0! Dumping standard error!",
path.display(),
);
let error = String::from_utf8_lossy(&output.stderr);
Err(BunBunError::CustomProgram(error.to_string()))
}
}
#[cfg(test)]
mod resolve_hop {
use super::*;
use anyhow::Result;
fn generate_route_result<'a>(keyword: &'a Route, args: &str) -> RouteResolution<'a> {
RouteResolution::Resolved {
route: keyword,
args: String::from(args),
}
}
#[test]
fn empty_routes_no_default_yields_failed_hop() {
assert_eq!(
resolve_hop("hello world", &HashMap::new(), None),
RouteResolution::Unresolved
);
}
#[test]
fn empty_routes_some_default_yields_failed_hop() {
assert_eq!(
resolve_hop("hello world", &HashMap::new(), Some(&"google")),
RouteResolution::Unresolved
);
}
#[test]
fn only_default_routes_some_default_yields_default_hop() -> Result<()> {
let mut map: HashMap<String, Route> = HashMap::new();
map.insert("google".into(), Route::from("https://example.com"));
assert_eq!(
resolve_hop("hello world", &map, Some("google")),
generate_route_result(&Route::from("https://example.com"), "hello world"),
);
Ok(())
}
#[test]
fn non_default_routes_some_default_yields_non_default_hop() -> Result<()> {
let mut map: HashMap<String, Route> = HashMap::new();
map.insert("google".into(), Route::from("https://example.com"));
assert_eq!(
resolve_hop("google hello world", &map, Some("a")),
generate_route_result(&Route::from("https://example.com"), "hello world"),
);
Ok(())
}
#[test]
fn non_default_routes_no_default_yields_non_default_hop() -> Result<()> {
let mut map: HashMap<String, Route> = HashMap::new();
map.insert("google".into(), Route::from("https://example.com"));
assert_eq!(
resolve_hop("google hello world", &map, None),
generate_route_result(&Route::from("https://example.com"), "hello world"),
);
Ok(())
}
}
#[cfg(test)]
mod check_route {
use super::*;
fn create_route(
min_args: impl Into<Option<usize>>,
max_args: impl Into<Option<usize>>,
) -> Route {
Route {
description: None,
hidden: false,
max_args: max_args.into(),
min_args: min_args.into(),
path: String::new(),
route_type: RouteType::External,
}
}
#[test]
fn no_min_arg_no_max_arg_counts() {
assert!(check_route(&create_route(None, None), 0));
assert!(check_route(&create_route(None, None), usize::MAX));
}
#[test]
fn min_arg_no_max_arg_counts() {
assert!(!check_route(&create_route(3, None), 0));
assert!(!check_route(&create_route(3, None), 2));
assert!(check_route(&create_route(3, None), 3));
assert!(check_route(&create_route(3, None), 4));
assert!(check_route(&create_route(3, None), usize::MAX));
}
#[test]
fn no_min_arg_max_arg_counts() {
assert!(check_route(&create_route(None, 3), 0));
assert!(check_route(&create_route(None, 3), 2));
assert!(check_route(&create_route(None, 3), 3));
assert!(!check_route(&create_route(None, 3), 4));
assert!(!check_route(&create_route(None, 3), usize::MAX));
}
#[test]
fn min_arg_max_arg_counts() {
assert!(!check_route(&create_route(2, 3), 1));
assert!(check_route(&create_route(2, 3), 2));
assert!(check_route(&create_route(2, 3), 3));
assert!(!check_route(&create_route(2, 3), 4));
}
}
#[cfg(test)]
mod resolve_path {
use crate::error::BunBunError;
use super::{resolve_path, HopAction};
use anyhow::Result;
use std::borrow::Cow;
use std::env::current_dir;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[test]
fn invalid_path_returns_err() {
assert!(resolve_path(&Path::new("/bin/aaaa"), "aaaa").is_err());
}
#[test]
fn valid_path_returns_ok() {
assert!(resolve_path(&Path::new("/bin/echo"), r#"{"body": "a"}"#).is_ok());
}
#[test]
fn relative_path_returns_ok() -> Result<()> {
// How many ".." needed to get to /
let nest_level = current_dir()?.ancestors().count() - 1;
let mut rel_path = PathBuf::from("../".repeat(nest_level));
rel_path.push("./bin/echo");
assert!(resolve_path(&rel_path, r#"{"body": "a"}"#).is_ok());
Ok(())
}
#[test]
fn no_permissions_returns_err() {
let result = match resolve_path(&Path::new("/root/some_exec"), "") {
Err(BunBunError::Io(e)) => e.kind() == ErrorKind::PermissionDenied,
_ => false,
};
assert!(result);
}
#[test]
fn non_success_exit_code_yields_err() {
// cat-ing a folder always returns exit code 1
assert!(resolve_path(&Path::new("/bin/cat"), "/").is_err());
}
#[test]
fn return_body() -> Result<()> {
assert_eq!(
resolve_path(&Path::new("/bin/echo"), r#"{"body": "a"}"#)?,
HopAction::Body("a".to_owned())
);
Ok(())
}
#[test]
fn return_redirect() -> Result<()> {
assert_eq!(
resolve_path(&Path::new("/bin/echo"), r#"{"redirect": "a"}"#)?,
HopAction::Redirect(Cow::Borrowed("a"))
);
Ok(())
}
}

View File

@ -1,17 +1,22 @@
use std::borrow::Cow;
use percent_encoding::PercentEncode;
use serde::Serialize;
pub fn query(query: String) -> impl Serialize {
#[derive(Serialize)]
struct TemplateArgs {
query: String,
}
TemplateArgs { query }
pub fn query(query: PercentEncode<'_>) -> impl Serialize + '_ {
#[derive(Serialize)]
struct TemplateArgs<'a> {
query: Cow<'a, str>,
}
TemplateArgs {
query: query.into(),
}
}
pub fn hostname(hostname: String) -> impl Serialize {
#[derive(Serialize)]
pub struct TemplateArgs {
pub hostname: String,
}
TemplateArgs { hostname }
pub fn hostname(hostname: &'_ str) -> impl Serialize + '_ {
#[derive(Serialize)]
pub struct TemplateArgs<'a> {
pub hostname: &'a str,
}
TemplateArgs { hostname }
}

View File

@ -2,11 +2,13 @@
<html lang="en">
<head>
<title>Bunbun</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Bunbun search multiplexer/jump service">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Bunbun search multiplexer/jump service" />
<style type="text/css">
body {
display: flex;
margin: 0;
min-height: 100vh;
flex-direction: column;
align-items: center;
background-color: #212121;
@ -14,8 +16,19 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
h1, p { margin: 0; }
h1 { margin-top: 1rem; }
main { display: flex; }
a { color: white; }
footer {
display: flex;
justify-self: end;
justify-content: space-between;
}
.spacer { flex-grow: 1; }
footer p, footer p a {
margin: 1rem 0.5rem;
color: #555;
}
</style>
<link rel="search"
type="application/opensearchdescription+xml"
@ -24,7 +37,7 @@
</head>
<body>
<h1>Bunbun</h1>
<p>Thanks for installing bunbun! To setup bunbun for your web browser, follow these steps:</p>
<p>Welcome to bunbun! To setup bunbun for your web browser, follow these steps:</p>
<main>
<section>
<h2>Firefox</h2>
@ -49,6 +62,11 @@
</ol>
</section>
</main>
<p>To view a full list of commands, check out the <a href="/ls">command list</a>.</p>
<p>To view a full list of commands currently available on this instance, check out the <a href="/ls">command list</a>.</p>
<div class="spacer"></div>
<footer>
<p>{{> bunbun_version }}</p>
<p><a href="{{> bunbun_src }}">Source Code</a></p>
</footer>
</body>
</html>

View File

@ -14,20 +14,49 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
h1, p { margin: 0; }
table { margin-top: 1em; }
td, th { padding: 0 0.5em; }
table { margin-bottom: 1rem; }
header { display: flex; flex-wrap: wrap; align-items: baseline; margin-top: 2rem; }
header h2 { margin: 0 1rem 0 0; }
i { color: rgba(255, 255, 255, 0.5); }
td, th { padding: 0 0.5rem; }
.shortcut { text-align: right; }
.description { text-align: left; width: 100%; }
footer {
margin-top: 1rem;
color: #444;
}
</style>
</head>
<body>
<h1>Bunbun Command List</h1>
<p><em>To edit this list, edit your <code>bunbun.toml</code> file.</em></p>
<table>
<tr>
<th>Shortcut</th>
<th>Target</th>
</tr>
{{#each this}}<tr><td class="shortcut">{{@key}}</td><td class="target">{{this}}</td></tr>{{/each}}
</table>
<p><i>To edit this list, edit your <code>bunbun.yaml</code> file.</i></p>
<main>
{{~#each this}} {{!-- Iterate over RouteGroup --}}
{{~#unless this.hidden}}
<header><h2>{{this.name}}</h2><i>{{this.description}}</i></header>
<table>
<tr>
<th>Shortcut</th>
<th class="description">Description</th>
</tr>
{{~#each this.routes}} {{!-- Iterate over Route --}}
{{~#unless this.hidden}}
<tr>
<td class="shortcut">{{@key}}</td>
{{~#if this.description~}}
<td class="description">{{this.description}}</td>
{{~else~}}
<td class="description">{{this.path}}</td>
{{~/if}}
</tr>
{{~/unless}}
{{~/each}}
</table>
{{~/unless}}
{{~/each}}
</main>
<footer>
<p>{{> bunbun_version}}</p>
</footer>
</body>
</html>

View File

@ -4,6 +4,6 @@
<Description>Hop to where you need to go</Description>
<InputEncoding>UTF-8</InputEncoding>
<!--<Image width="16" height="16">data:image/x-icon;base64,</Image>-->
<Url type="text/html" template="http://{{hostname}}/hop?to={searchTerms}"></Url>
<Url type="application/x-moz-keywordsearch" template="http://{{hostname}}/hop?to={searchTerms}"></Url>
<Url type="text/html" template="http://{{hostname}}/hop?to={searchTerms}" />
<Url type="application/x-moz-keywordsearch" template="http://{{hostname}}/hop?to={searchTerms}" />
</OpenSearchDescription>