Compare commits

..

No commits in common. "master" and "0.6.2" have entirely different histories.

18 changed files with 2085 additions and 1929 deletions

1
.gitignore vendored
View file

@ -2,4 +2,3 @@
**/*.rs.bk **/*.rs.bk
bunbun.toml bunbun.toml
out out
tarpaulin-report.html

2125
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -6,7 +6,7 @@
# file would be part of said release. # file would be part of said release.
pkgname=bunbun pkgname=bunbun
pkgver=0.8.0 pkgver=0.6.1
pkgrel=1 pkgrel=1
depends=('gcc-libs') depends=('gcc-libs')
makedepends=('rust' 'cargo') makedepends=('rust' 'cargo')
@ -15,7 +15,7 @@ pkgdesc="Re-implementation of bunny1 in Rust"
url="https://github.com/edward-shen/bunbun" url="https://github.com/edward-shen/bunbun"
license=('AGPL') license=('AGPL')
source=("$pkgname-$pkgver.tar.gz::https://github.com/edward-shen/$pkgname/archive/$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::https://github.com/edward-shen/$pkgname/archive/$pkgver.tar.gz")
sha512sums=('55ecc42176e57863c87d7196e41f4971694eda7d74200214e2a64b6bb3b54c5990ab224301253317e38b079842318315891159113b6de754cd91171c808660bb') sha512sums=('f3781cbb4e9e190df38c3fe7fa80ba69bf6f9dbafb158e0426dd4604f2f1ba794450679005a38d0f9f1dad0696e2f22b8b086b2d7d08a0f99bb4fd3b0f7ed5d8')
build() { build() {
cd "$pkgname-$pkgver" cd "$pkgname-$pkgver"

View file

@ -51,7 +51,8 @@ start bunbun as a daemon.
If you're looking to build a release binary, here are the steps I use: If you're looking to build a release binary, here are the steps I use:
1. `cargo build --release` 1. `cargo build --release`
2. `upx --lzma target/release/bunbun` 2. `strip target/release/bunbun`
3. `upx --lzma target/release/bunbun`
LZMA provides the best level of compress for Rust binaries; it performs at the 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 same level as `upx --ultra-brute` without the time cost and [without breaking

View file

@ -17,64 +17,30 @@ default_route: "g"
# defined route is used. # defined route is used.
# #
# You may provide an (absolute, recommended) path to an executable file to out- # 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 # source route resolution to a program. The program will receive one argument
# as space-separated words, without any shell parsing. # only, which is the entire string provided from the user after matching the
# # route. It is up to the out-sourced program to parse the arguments and to
# These programs must return a JSON object with either one of the following # interpret those arguments. These programs should print one line to standard
# key-value pairs: # out, which should be a fully resolved URL to lead the user to.
# - "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 # These programs must be developed defensively, as they accept arbitrary user
# input. Improper handling of user input can easily lead to anywhere from simple # input. Improper handling of user input can easily lead to anywhere from simple
# flakey responses to remote code execution. # flakey responses to remote code execution.
groups: groups:
- -
# This is a group with the name "Meta commands" with a short description.
name: "Meta commands" name: "Meta commands"
description: "Commands for bunbun" description: "Commands for bunbun"
routes: routes:
# /ls is the only page that comes with bunbun besides the homepage. This ls: &ls "/ls"
# page provides a full list of routes and their groups they're in. help: *ls
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 list: *ls
- -
# This is another group without a description
name: "Google" name: "Google"
routes: 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}}" g: "https://google.com/search?q={{query}}"
# Alternatively, you can provide a description instead, which provides yt: "https://www.youtube.com/results?search_query={{query}}"
# 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" name: "Uncategorized routes"
description: "One-off routes with no specific grouping"
routes: routes:
r: "https://reddit.com/r/{{query}}" 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"

View file

@ -1,11 +0,0 @@
#!/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"

3
rustfmt.toml Normal file
View file

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

View file

@ -1,17 +0,0 @@
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,
}

21
src/cli.yaml Normal file
View file

@ -0,0 +1,21 @@
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"
- config:
short: "c"
long: "config"
default_value: "/etc/bunbun.yaml"
help: Specify the location of the config file to read from. Needs read/write permissions.

View file

@ -1,24 +1,17 @@
use crate::BunBunError; use crate::BunBunError;
use dirs::{config_dir, home_dir}; use log::{debug, error, info, trace};
use serde::{ use serde::{
de::{self, Deserializer, MapAccess, Unexpected, Visitor}, de::{Deserializer, Visitor},
Deserialize, Serialize, Deserialize, Serialize, Serializer,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt; use std::fmt;
use std::fs::{File, OpenOptions}; use std::fs::{read_to_string, OpenOptions};
use std::io::{Read, Write}; use std::io::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"); 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)] #[derive(Deserialize, Debug, PartialEq)]
pub struct Config { pub struct Config {
pub bind_address: String, pub bind_address: String,
pub public_address: String, pub public_address: String,
@ -26,47 +19,30 @@ pub struct Config {
pub groups: Vec<RouteGroup>, pub groups: Vec<RouteGroup>,
} }
#[derive(Deserialize, Serialize, Debug, Eq, PartialEq, Clone)] #[derive(Deserialize, Serialize, Debug, PartialEq, Clone)]
pub struct RouteGroup { pub struct RouteGroup {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
#[serde(default)]
pub hidden: bool,
pub routes: HashMap<String, Route>, pub routes: HashMap<String, Route>,
} }
#[derive(Debug, PartialEq, Eq, Clone, Serialize)] #[derive(Debug, PartialEq, Clone)]
pub struct Route { pub enum Route {
pub route_type: RouteType, External(String),
pub path: String, Path(String),
pub hidden: bool,
pub description: Option<String>,
pub min_args: Option<usize>,
pub max_args: Option<usize>,
} }
impl From<String> for Route { /// Serialization of the Route enum needs to be transparent, but since the
fn from(s: String) -> Self { /// `#[serde(transparent)]` macro isn't available on enums, so we need to
Self { /// implement it manually.
route_type: get_route_type(&s), impl Serialize for Route {
path: s, fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
hidden: false, where
description: None, S: Serializer,
min_args: None, {
max_args: None, match self {
} Self::External(s) => serializer.serialize_str(s),
} Self::Path(s) => serializer.serialize_str(s),
}
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,
} }
} }
} }
@ -77,22 +53,11 @@ impl From<&'static str> for Route {
/// web path. This incurs a disk check operation, but since users shouldn't be /// web path. This incurs a disk check operation, but since users shouldn't be
/// updating the config that frequently, it should be fine. /// updating the config that frequently, it should be fine.
impl<'de> Deserialize<'de> for Route { impl<'de> Deserialize<'de> for Route {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Route, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
#[derive(Deserialize)]
#[serde(field_identifier, rename_all = "snake_case")]
enum Field {
Path,
Hidden,
Description,
MinArgs,
MaxArgs,
}
struct RouteVisitor; struct RouteVisitor;
impl<'de> Visitor<'de> for RouteVisitor { impl<'de> Visitor<'de> for RouteVisitor {
type Value = Route; type Value = Route;
@ -100,327 +65,194 @@ impl<'de> Deserialize<'de> for Route {
formatter.write_str("string") formatter.write_str("string")
} }
fn visit_str<E>(self, path: &str) -> Result<Self::Value, E> fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(Self::Value::from(path.to_owned())) // Return early if it's a path, don't go through URL parsing
} if std::path::Path::new(value).exists() {
debug!("Parsed {} as a valid local path.", value);
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error> Ok(Route::Path(value.into()))
where } else {
M: MapAccess<'de>, debug!("{} does not exist on disk, assuming web path.", value);
{ Ok(Route::External(value.into()))
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) { deserializer.deserialize_str(RouteVisitor)
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 { impl std::fmt::Display for Route {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self { Self::External(s) => write!(f, "raw ({})", s),
route_type: RouteType::External, Self::Path(s) => write!(f, "file ({})", s),
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. /// Attempts to read the config file. If it doesn't exist, generate one a
fn get_route_type(path: &str) -> RouteType { /// default config file before attempting to parse it.
if std::path::Path::new(path).exists() { pub fn read_config(config_file_path: &str) -> Result<Config, BunBunError> {
debug!("Parsed {path} as a valid local path."); trace!("Loading config file...");
RouteType::Internal let config_str = match read_to_string(config_file_path) {
} else { Ok(conf_str) => {
debug!("{path} does not exist on disk, assuming web path."); debug!("Successfully loaded config file into memory.");
RouteType::External conf_str
} }
} Err(_) => {
info!(
"Unable to find a {} file. Creating default!",
config_file_path
);
/// There exists two route types: an external path (e.g. a URL) or an internal let fd = OpenOptions::new()
/// 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) .write(true)
.create_new(true) .create_new(true)
.open(location.clone()); .open(config_file_path);
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())?; match fd {
return Ok(FileData { Ok(mut fd) => fd.write_all(DEFAULT_CONFIG)?,
path: location,
file,
});
}
Err(e) => { Err(e) => {
debug!("Tried to open a new file at '{location:?}' but failed due to error: {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()
} }
};
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 // Reading from memory is faster than reading directly from a reader for some
// reason; see https://github.com/serde-rs/json/issues/160 // reason; see https://github.com/serde-rs/json/issues/160
Ok(serde_yaml::from_str(&config_data)?) Ok(serde_yaml::from_str(&config_str)?)
} }
#[cfg(test)] #[cfg(test)]
mod route { mod route {
use super::*; use super::*;
use anyhow::{Context, Result};
use serde_yaml::{from_str, to_string}; use serde_yaml::{from_str, to_string};
use std::path::Path;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[test] #[test]
fn deserialize_relative_path() -> Result<()> { fn deserialize_relative_path() {
let tmpfile = NamedTempFile::new_in(".")?; let tmpfile = NamedTempFile::new_in(".").unwrap();
let path = tmpfile.path().display().to_string(); let path = format!("{}", tmpfile.path().display());
let path = path let path = path.get(path.rfind(".").unwrap()..).unwrap();
.get(path.rfind(".").context("While finding .")?..) let path = std::path::Path::new(path);
.context("While getting the path")?;
let path = Path::new(path);
assert!(path.is_relative()); assert!(path.is_relative());
let path = path.to_str().context("While stringifying path")?; let path = path.to_str().unwrap();
assert_eq!(from_str::<Route>(path)?, Route::from(path.to_owned())); assert_eq!(from_str::<Route>(path).unwrap(), Route::Path(path.into()));
Ok(())
} }
#[test] #[test]
fn deserialize_absolute_path() -> Result<()> { fn deserialize_absolute_path() {
let tmpfile = NamedTempFile::new()?; let tmpfile = NamedTempFile::new().unwrap();
let path = format!("{}", tmpfile.path().display()); let path = format!("{}", tmpfile.path().display());
assert!(tmpfile.path().is_absolute()); assert!(tmpfile.path().is_absolute());
assert_eq!(from_str::<Route>(&path)?, Route::from(path)); assert_eq!(from_str::<Route>(&path).unwrap(), Route::Path(path));
Ok(())
} }
#[test] #[test]
fn deserialize_http_path() -> Result<()> { fn deserialize_http_path() {
assert_eq!( assert_eq!(
from_str::<Route>("http://google.com")?, from_str::<Route>("http://google.com").unwrap(),
Route::from("http://google.com") Route::External("http://google.com".into())
); );
Ok(())
} }
#[test] #[test]
fn deserialize_https_path() -> Result<()> { fn deserialize_https_path() {
assert_eq!( assert_eq!(
from_str::<Route>("https://google.com")?, from_str::<Route>("https://google.com").unwrap(),
Route::from("https://google.com") Route::External("https://google.com".into())
); );
Ok(())
} }
#[test] #[test]
fn serialize() -> Result<()> { fn serialize() {
assert_eq!( assert_eq!(
&to_string(&Route::from("hello world"))?, &to_string(&Route::External("hello world".into())).unwrap(),
"---\nroute_type: External\npath: hello world\nhidden: false\ndescription: ~\nmin_args: ~\nmax_args: ~\n" "---\nhello world"
);
assert_eq!(
&to_string(&Route::Path("hello world".into())).unwrap(),
"---\nhello world"
); );
Ok(())
} }
} }
#[cfg(test)] #[cfg(test)]
mod read_config { mod read_config {
use super::*; use super::*;
use anyhow::Result; use tempfile::NamedTempFile;
#[test] #[test]
fn empty_file() -> Result<()> { fn returns_default_config_if_path_does_not_exist() {
let config_file = tempfile::tempfile()?; assert_eq!(
assert!(matches!( read_config("/this_is_a_non_existent_file").unwrap(),
load_file(config_file, false), serde_yaml::from_slice(DEFAULT_CONFIG).unwrap()
Err(BunBunError::ZeroByteConfig) );
)); }
#[test]
fn returns_error_if_given_empty_config() {
assert_eq!(
read_config("/dev/null").unwrap_err().to_string(),
"EOF while parsing a value"
);
}
#[test]
fn returns_error_if_given_invalid_config() -> Result<(), std::io::Error> {
let mut tmp_file = NamedTempFile::new()?;
tmp_file.write_all(b"g")?;
assert_eq!(
read_config(tmp_file.path().to_str().unwrap())
.unwrap_err()
.to_string(),
r#"invalid type: string "g", expected struct Config at line 1 column 1"#
);
Ok(()) Ok(())
} }
#[test] #[test]
fn config_too_large() -> Result<()> { fn returns_error_if_config_missing_field() -> Result<(), std::io::Error> {
let mut config_file = tempfile::tempfile()?; let mut tmp_file = NamedTempFile::new()?;
let size_to_write = (LARGE_FILE_SIZE_THRESHOLD + 1) as usize; tmp_file.write_all(
config_file.write(&[0].repeat(size_to_write))?; br#"
match load_file(config_file, false) { bind_address: "localhost"
Err(BunBunError::ConfigTooLarge(size)) if size as usize == size_to_write => {} public_address: "localhost"
Err(BunBunError::ConfigTooLarge(size)) => { "#,
panic!("Mismatched size: {size} != {size_to_write}") )?;
} assert_eq!(
res => panic!("Wrong result, got {res:#?}"), read_config(tmp_file.path().to_str().unwrap())
} .unwrap_err()
.to_string(),
"missing field `groups` at line 2 column 19"
);
Ok(()) Ok(())
} }
#[test] #[test]
fn valid_config() -> Result<()> { fn returns_ok_if_valid_config() -> Result<(), std::io::Error> {
assert!(load_file(File::open("bunbun.default.yaml")?, false).is_ok()); let mut tmp_file = NamedTempFile::new()?;
tmp_file.write_all(
br#"
bind_address: "a"
public_address: "b"
groups: []"#,
)?;
assert_eq!(
read_config(tmp_file.path().to_str().unwrap()).unwrap(),
Config {
bind_address: String::from("a"),
public_address: String::from("b"),
groups: vec![],
default_route: None,
}
);
Ok(()) Ok(())
} }
} }

View file

@ -2,17 +2,13 @@ use std::error::Error;
use std::fmt; use std::fmt;
#[derive(Debug)] #[derive(Debug)]
#[allow(clippy::module_name_repetitions)] #[allow(clippy::enum_variant_names)]
pub enum BunBunError { pub enum BunBunError {
Io(std::io::Error), IoError(std::io::Error),
Parse(serde_yaml::Error), ParseError(serde_yaml::Error),
Watch(hotwatch::Error), WatchError(hotwatch::Error),
CustomProgram(String), LoggerInitError(log::SetLoggerError),
NoValidConfigPath, CustomProgramError(String),
InvalidConfigPath(std::path::PathBuf, std::io::Error),
ConfigTooLarge(u64),
ZeroByteConfig,
JsonParse(serde_json::Error),
} }
impl Error for BunBunError {} impl Error for BunBunError {}
@ -20,17 +16,11 @@ impl Error for BunBunError {}
impl fmt::Display for BunBunError { impl fmt::Display for BunBunError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self { match self {
Self::Io(e) => e.fmt(f), Self::IoError(e) => e.fmt(f),
Self::Parse(e) => e.fmt(f), Self::ParseError(e) => e.fmt(f),
Self::Watch(e) => e.fmt(f), Self::WatchError(e) => e.fmt(f),
Self::CustomProgram(msg) => msg.fmt(f), Self::LoggerInitError(e) => e.fmt(f),
Self::NoValidConfigPath => write!(f, "No valid config path was found!"), Self::CustomProgramError(msg) => write!(f, "{}", msg),
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),
} }
} }
} }
@ -47,7 +37,7 @@ macro_rules! from_error {
}; };
} }
from_error!(std::io::Error, Io); from_error!(std::io::Error, IoError);
from_error!(serde_yaml::Error, Parse); from_error!(serde_yaml::Error, ParseError);
from_error!(hotwatch::Error, Watch); from_error!(hotwatch::Error, WatchError);
from_error!(serde_json::Error, JsonParse); from_error!(log::SetLoggerError, LoggerInitError);

View file

@ -1,33 +1,20 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![deny(missing_docs)]
#![warn(clippy::nursery, clippy::pedantic)]
//! Bunbun is a pure-Rust implementation of bunny1 that provides a customizable use crate::config::{read_config, Route, RouteGroup};
//! search engine and quick-jump tool in one small binary. For information on use actix_web::{middleware::Logger, App, HttpServer};
//! usage, please take a look at the readme. use clap::{crate_authors, crate_version, load_yaml, App as ClapApp};
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 error::BunBunError;
use handlebars::Handlebars; use handlebars::Handlebars;
use hotwatch::{Event, Hotwatch}; use hotwatch::{Event, Hotwatch};
use log::{debug, info, trace, warn};
use std::cmp::min;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use tracing::{debug, info, trace, warn};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
mod cli;
mod config; mod config;
#[cfg(not(tarpaulin_include))]
mod error; mod error;
mod routes; mod routes;
#[cfg(not(tarpaulin_include))]
mod template_args; mod template_args;
/// Dynamic variables that either need to be present at runtime, or can be /// Dynamic variables that either need to be present at runtime, or can be
@ -40,67 +27,85 @@ pub struct State {
routes: HashMap<String, Route>, routes: HashMap<String, Route>,
} }
#[tokio::main] #[actix_rt::main]
#[cfg(not(tarpaulin_include))] async fn main() -> Result<(), BunBunError> {
async fn main() -> Result<()> { let yaml = load_yaml!("cli.yaml");
use tracing_subscriber::EnvFilter; let matches = ClapApp::from(yaml)
.version(crate_version!())
.author(crate_authors!())
.get_matches();
let opts = cli::Opts::parse(); init_logger(
matches.occurrences_of("verbose"),
matches.occurrences_of("quiet"),
)?;
let mut env_filter = EnvFilter::from_default_env(); // config has default location provided, unwrapping is fine.
for directive in opts.log { let conf_file_location = String::from(matches.value_of("config").unwrap());
env_filter = env_filter.add_directive(directive); let conf = read_config(&conf_file_location)?;
} let state = Arc::from(RwLock::new(State {
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(env_filter)
.init();
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, public_address: conf.public_address,
default_route: conf.default_route, default_route: conf.default_route,
routes: cache_routes(conf.groups.clone()), routes: cache_routes(&conf.groups),
groups: conf.groups, groups: conf.groups,
})); }));
// Cannot be named _ or Rust will immediately drop it. let _watch = start_watch(state.clone(), conf_file_location)?;
let _watch = start_watch(Arc::clone(&state), conf_data, opts.large_config);
let app = Router::new() HttpServer::new(move || {
.route("/", get(routes::index)) App::new()
.route("/bunbunsearch.xml", get(routes::opensearch)) .data(state.clone())
.route("/ls", get(routes::list)) .app_data(compile_templates())
.route("/hop", get(routes::hop)) .wrap(Logger::default())
.layer(Extension(compile_templates()?)) .service(routes::hop)
.layer(Extension(state)); .service(routes::list)
.service(routes::index)
let bind_addr = conf.bind_address.parse()?; .service(routes::opensearch)
})
info!("Starting server at {bind_addr}"); .bind(&conf.bind_address)?
.run()
axum::Server::bind(&bind_addr)
.serve(app.into_make_service())
.await?; .await?;
Ok(()) Ok(())
} }
/// Initializes the logger based on the number of quiet and verbose flags passed
/// in. Usually, these values are mutually exclusive, that is, if the number of
/// verbose flags is non-zero then the quiet flag is zero, and vice versa.
fn init_logger(
num_verbose_flags: u64,
num_quiet_flags: u64,
) -> Result<(), BunBunError> {
let log_level =
match min(num_verbose_flags, 3) as i8 - min(num_quiet_flags, 2) as i8 {
-2 => None,
-1 => Some(log::Level::Error),
0 => Some(log::Level::Warn),
1 => Some(log::Level::Info),
2 => Some(log::Level::Debug),
3 => Some(log::Level::Trace),
_ => unreachable!(), // values are clamped to [0, 3] - [0, 2]
};
if let Some(level) = log_level {
simple_logger::init_with_level(level)?;
}
Ok(())
}
/// Generates a hashmap of routes from the data structure created by the config /// 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 /// file. This should improve runtime performance and is a better solution than
/// just iterating over the config object for every hop resolution. /// just iterating over the config object for every hop resolution.
fn cache_routes(groups: Vec<RouteGroup>) -> HashMap<String, Route> { fn cache_routes(groups: &[RouteGroup]) -> HashMap<String, Route> {
let mut mapping = HashMap::new(); let mut mapping = HashMap::new();
for group in groups { for group in groups {
for (kw, dest) in group.routes { for (kw, dest) in &group.routes {
// This function isn't called often enough to not be a performance issue. match mapping.insert(kw.clone(), dest.clone()) {
if let Some(old_value) = mapping.insert(kw.clone(), dest.clone()) { None => trace!("Inserting {} into mapping.", kw),
trace!("Overriding {kw} route from {old_value} to {dest}."); Some(old_value) => {
} else { debug!("Overriding {} route from {} to {}.", kw, old_value, dest)
trace!("Inserting {kw} into mapping."); }
} }
} }
} }
@ -110,11 +115,15 @@ fn cache_routes(groups: Vec<RouteGroup>) -> HashMap<String, Route> {
/// Returns an instance with all pre-generated templates included into the /// Returns an instance with all pre-generated templates included into the
/// binary. This allows for users to have a portable binary without needed the /// binary. This allows for users to have a portable binary without needed the
/// templates at runtime. /// templates at runtime.
fn compile_templates() -> Result<Handlebars<'static>> { fn compile_templates() -> Handlebars {
let mut handlebars = Handlebars::new(); let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true); handlebars.set_strict_mode(true);
handlebars.register_partial("bunbun_version", env!("CARGO_PKG_VERSION"))?; handlebars
handlebars.register_partial("bunbun_src", env!("CARGO_PKG_REPOSITORY"))?; .register_partial("bunbun_version", env!("CARGO_PKG_VERSION"))
.unwrap();
handlebars
.register_partial("bunbun_src", env!("CARGO_PKG_REPOSITORY"))
.unwrap();
macro_rules! register_template { macro_rules! register_template {
[ $( $template:expr ),* ] => { [ $( $template:expr ),* ] => {
$( $(
@ -123,13 +132,14 @@ fn compile_templates() -> Result<Handlebars<'static>> {
$template, $template,
String::from_utf8_lossy( String::from_utf8_lossy(
include_bytes!(concat!("templates/", $template, ".hbs"))) include_bytes!(concat!("templates/", $template, ".hbs")))
)?; )
.unwrap();
debug!("Loaded {} template.", $template); debug!("Loaded {} template.", $template);
)* )*
}; };
} }
register_template!["index", "list", "opensearch"]; register_template!["index", "list", "opensearch"];
Ok(handlebars) handlebars
} }
/// Starts the watch on a file, if possible. This will only return an Error if /// Starts the watch on a file, if possible. This will only return an Error if
@ -141,70 +151,95 @@ fn compile_templates() -> Result<Handlebars<'static>> {
/// ///
/// This watch object should be kept in scope as dropping it releases all /// This watch object should be kept in scope as dropping it releases all
/// watches. /// watches.
#[cfg(not(tarpaulin_include))]
fn start_watch( fn start_watch(
state: Arc<ArcSwap<State>>, state: Arc<RwLock<State>>,
config_data: FileData, config_file_path: String,
large_config: bool, ) -> Result<Hotwatch, BunBunError> {
) -> Result<Hotwatch> {
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?; let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?;
let FileData { path, mut file } = config_data; // TODO: keep retry watching in separate thread
let watch_result = watch.watch(&path, move |e: Event| { // Closures need their own copy of variables for proper life cycle management
if let Event::Create(ref path) = e { let config_file_path_clone = config_file_path.clone();
file = load_custom_file(path).expect("file to exist at path").file; let watch_result = watch.watch(&config_file_path, move |e: Event| {
trace!("Getting new file handler as file was recreated."); if let Event::Write(_) = e {
}
match e {
Event::Write(_) | Event::Create(_) => {
trace!("Grabbing writer lock on state..."); trace!("Grabbing writer lock on state...");
let mut state = state.write().unwrap();
trace!("Obtained writer lock on state!"); trace!("Obtained writer lock on state!");
match load_file( match read_config(&config_file_path_clone) {
file.try_clone().expect("Failed to clone file handle"),
large_config,
) {
Ok(conf) => { Ok(conf) => {
state.store(Arc::new(State { state.public_address = conf.public_address;
public_address: conf.public_address, state.default_route = conf.default_route;
default_route: conf.default_route, state.routes = cache_routes(&conf.groups);
routes: cache_routes(conf.groups.clone()), state.groups = conf.groups;
groups: conf.groups,
}));
info!("Successfully updated active state"); info!("Successfully updated active state");
} }
Err(e) => warn!("Failed to update config file: {e}"), Err(e) => warn!("Failed to update config file: {}", e),
} }
} } else {
_ => debug!("Saw event {e:#?} but ignored it"), debug!("Saw event {:#?} but ignored it", e);
} }
}); });
match watch_result { match watch_result {
Ok(_) => info!("Watcher is now watching {path:?}"), Ok(_) => info!("Watcher is now watching {}", &config_file_path),
Err(e) => { Err(e) => warn!(
warn!("Couldn't watch {path:?}: {e}. Changes to this file won't be seen!"); "Couldn't watch {}: {}. Changes to this file won't be seen!",
} &config_file_path, e
),
} }
Ok(watch) Ok(watch)
} }
#[cfg(test)]
mod init_logger {
use super::*;
#[test]
fn defaults_to_warn() -> Result<(), BunBunError> {
init_logger(0, 0)?;
assert_eq!(log::max_level(), log::Level::Warn);
Ok(())
}
// The following tests work but because the log crate is global, initializing
// the logger more than once (read: testing it more than once) leads to a
// panic. These ignored tests must be manually tested.
#[test]
#[ignore]
fn caps_to_2_when_log_level_is_lt_2() -> Result<(), BunBunError> {
init_logger(0, 3)?;
assert_eq!(log::max_level(), log::LevelFilter::Off);
Ok(())
}
#[test]
#[ignore]
fn caps_to_3_when_log_level_is_gt_3() -> Result<(), BunBunError> {
init_logger(4, 0)?;
assert_eq!(log::max_level(), log::Level::Trace);
Ok(())
}
}
#[cfg(test)] #[cfg(test)]
mod cache_routes { mod cache_routes {
use super::*; use super::*;
use std::iter::FromIterator; use std::iter::FromIterator;
fn generate_external_routes(routes: &[(&'static str, &'static str)]) -> HashMap<String, Route> { fn generate_external_routes(
routes: &[(&str, &str)],
) -> HashMap<String, Route> {
HashMap::from_iter( HashMap::from_iter(
routes routes
.into_iter() .iter()
.map(|(key, value)| ((*key).to_owned(), Route::from(*value))), .map(|(k, v)| ((*k).into(), Route::External((*v).into()))),
) )
} }
#[test] #[test]
fn empty_groups_yield_empty_routes() { fn empty_groups_yield_empty_routes() {
assert_eq!(cache_routes(Vec::new()), HashMap::new()); assert_eq!(cache_routes(&[]), HashMap::new());
} }
#[test] #[test]
@ -213,19 +248,22 @@ mod cache_routes {
name: String::from("x"), name: String::from("x"),
description: Some(String::from("y")), description: Some(String::from("y")),
routes: generate_external_routes(&[("a", "b"), ("c", "d")]), routes: generate_external_routes(&[("a", "b"), ("c", "d")]),
hidden: false,
}; };
let group2 = RouteGroup { let group2 = RouteGroup {
name: String::from("5"), name: String::from("5"),
description: Some(String::from("6")), description: Some(String::from("6")),
routes: generate_external_routes(&[("1", "2"), ("3", "4")]), routes: generate_external_routes(&[("1", "2"), ("3", "4")]),
hidden: false,
}; };
assert_eq!( assert_eq!(
cache_routes(vec![group1, group2]), cache_routes(&[group1, group2]),
generate_external_routes(&[("a", "b"), ("c", "d"), ("1", "2"), ("3", "4")]) generate_external_routes(&[
("a", "b"),
("c", "d"),
("1", "2"),
("3", "4")
])
); );
} }
@ -235,18 +273,16 @@ mod cache_routes {
name: String::from("x"), name: String::from("x"),
description: Some(String::from("y")), description: Some(String::from("y")),
routes: generate_external_routes(&[("a", "b"), ("c", "d")]), routes: generate_external_routes(&[("a", "b"), ("c", "d")]),
hidden: false,
}; };
let group2 = RouteGroup { let group2 = RouteGroup {
name: String::from("5"), name: String::from("5"),
description: Some(String::from("6")), description: Some(String::from("6")),
routes: generate_external_routes(&[("a", "1"), ("c", "2")]), routes: generate_external_routes(&[("a", "1"), ("c", "2")]),
hidden: false,
}; };
assert_eq!( assert_eq!(
cache_routes(vec![group1.clone(), group2]), cache_routes(&[group1.clone(), group2]),
generate_external_routes(&[("a", "1"), ("c", "2")]) generate_external_routes(&[("a", "1"), ("c", "2")])
); );
@ -254,11 +290,10 @@ mod cache_routes {
name: String::from("5"), name: String::from("5"),
description: Some(String::from("6")), description: Some(String::from("6")),
routes: generate_external_routes(&[("a", "1"), ("b", "2")]), routes: generate_external_routes(&[("a", "1"), ("b", "2")]),
hidden: false,
}; };
assert_eq!( assert_eq!(
cache_routes(vec![group1, group3]), cache_routes(&[group1, group3]),
generate_external_routes(&[("a", "1"), ("b", "2"), ("c", "d")]) generate_external_routes(&[("a", "1"), ("b", "2"), ("c", "d")])
); );
} }

View file

@ -1,144 +1,123 @@
use crate::config::{Route as ConfigRoute, RouteType};
use crate::{template_args, BunBunError, Route, State}; use crate::{template_args, BunBunError, Route, State};
use arc_swap::ArcSwap; use actix_web::web::{Data, Query};
use axum::body::{boxed, Bytes, Empty, Full}; use actix_web::{get, http::header};
use axum::extract::Query; use actix_web::{HttpRequest, HttpResponse, Responder};
use axum::http::{header, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Extension;
use handlebars::Handlebars; use handlebars::Handlebars;
use itertools::Itertools;
use log::{debug, error};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use serde::Deserialize; use serde::Deserialize;
use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::sync::Arc; use std::sync::{Arc, RwLock};
use tracing::{debug, error};
// https://url.spec.whatwg.org/#fragment-percent-encode-set /// https://url.spec.whatwg.org/#fragment-percent-encode-set
const FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS const FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ') .add(b' ')
.add(b'"') .add(b'"')
.add(b'<') .add(b'<')
.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'\'');
#[allow(clippy::unused_async)] type StateData = Data<Arc<RwLock<State>>>;
pub async fn index(
Extension(data): Extension<Arc<ArcSwap<State>>>, #[get("/")]
Extension(handlebars): Extension<Handlebars<'static>>, pub async fn index(data: StateData, req: HttpRequest) -> impl Responder {
) -> impl IntoResponse { let data = data.read().unwrap();
handlebars HttpResponse::Ok()
.set_header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(
req
.app_data::<Handlebars>()
.unwrap()
.render( .render(
"index", "index",
&template_args::hostname(&data.load().public_address), &template_args::hostname(data.public_address.clone()),
)
.unwrap(),
) )
.map(Html)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
#[allow(clippy::unused_async)] #[get("/bunbunsearch.xml")]
pub async fn opensearch( pub async fn opensearch(data: StateData, req: HttpRequest) -> impl Responder {
Extension(data): Extension<Arc<ArcSwap<State>>>, let data = data.read().unwrap();
Extension(handlebars): Extension<Handlebars<'static>>, HttpResponse::Ok()
) -> impl IntoResponse { .header(
handlebars
.render(
"opensearch",
&template_args::hostname(&data.load().public_address),
)
.map(|body| {
(
StatusCode::OK,
[(
header::CONTENT_TYPE, header::CONTENT_TYPE,
"application/opensearchdescription+xml", "application/opensearchdescription+xml",
)],
body,
) )
}) .body(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) req
.app_data::<Handlebars>()
.unwrap()
.render(
"opensearch",
&template_args::hostname(data.public_address.clone()),
)
.unwrap(),
)
} }
#[allow(clippy::unused_async)] #[get("/ls")]
pub async fn list( pub async fn list(data: StateData, req: HttpRequest) -> impl Responder {
Extension(data): Extension<Arc<ArcSwap<State>>>, let data = data.read().unwrap();
Extension(handlebars): Extension<Handlebars<'static>>, HttpResponse::Ok()
) -> impl IntoResponse { .set_header(header::CONTENT_TYPE, "text/html; charset=utf-8")
handlebars .body(
.render("list", &data.load().groups) req
.map(Html) .app_data::<Handlebars>()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .unwrap()
.render("list", &data.groups)
.unwrap(),
)
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize)]
pub struct SearchQuery { pub struct SearchQuery {
to: String, to: String,
} }
#[allow(clippy::unused_async)] #[get("/hop")]
pub async fn hop( pub async fn hop(
Extension(data): Extension<Arc<ArcSwap<State>>>, data: StateData,
Extension(handlebars): Extension<Handlebars<'static>>, req: HttpRequest,
Query(query): Query<SearchQuery>, query: Query<SearchQuery>,
) -> impl IntoResponse { ) -> impl Responder {
let data = data.load(); let data = data.read().unwrap();
match resolve_hop(&query.to, &data.routes, data.default_route.as_deref()) { match resolve_hop(&query.to, &data.routes, &data.default_route) {
RouteResolution::Resolved { route: path, args } => { (Some(path), args) => {
let resolved_template = match path { let resolved_template = match path {
ConfigRoute { Route::Path(path) => resolve_path(PathBuf::from(path), &args),
route_type: RouteType::Internal, Route::External(path) => Ok(path.to_owned().into_bytes()),
path,
..
} => resolve_path(Path::new(path), &args),
ConfigRoute {
route_type: RouteType::External,
path,
..
} => Ok(HopAction::Redirect(Cow::Borrowed(path))),
}; };
match resolved_template { match resolved_template {
Ok(HopAction::Redirect(path)) => { Ok(path) => HttpResponse::Found()
let rendered = handlebars .header(
header::LOCATION,
req
.app_data::<Handlebars>()
.unwrap()
.render_template( .render_template(
&path, std::str::from_utf8(&path).unwrap(),
&template_args::query(utf8_percent_encode(&args, FRAGMENT_ENCODE_SET)), &template_args::query(
utf8_percent_encode(&args, FRAGMENT_ENCODE_SET).to_string(),
),
) )
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .unwrap(),
Response::builder() )
.status(StatusCode::FOUND) .finish(),
.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) => { Err(e) => {
error!("Failed to redirect user for {path}: {e}"); error!("Failed to redirect user for {}: {}", path, e);
Response::builder() HttpResponse::InternalServerError().body("Something went wrong :(\n")
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(boxed(Full::from("Something went wrong :(\n")))
} }
} }
} }
RouteResolution::Unresolved => Response::builder() (None, _) => HttpResponse::NotFound().body("not found"),
.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. /// Attempts to resolve the provided string into its route and its arguments.
@ -150,262 +129,190 @@ enum RouteResolution<'a> {
fn resolve_hop<'a>( fn resolve_hop<'a>(
query: &str, query: &str,
routes: &'a HashMap<String, Route>, routes: &'a HashMap<String, Route>,
default_route: Option<&str>, default_route: &Option<String>,
) -> RouteResolution<'a> { ) -> (Option<&'a Route>, String) {
let mut split_args = query.split_ascii_whitespace().peekable(); let mut split_args = query.split_ascii_whitespace().peekable();
let maybe_route = if let Some(command) = split_args.peek() { let command = match split_args.peek() {
routes.get(*command) Some(command) => command,
} else { None => {
debug!("Found empty query, returning no route."); debug!("Found empty query, returning no route.");
return RouteResolution::Unresolved; return (None, String::new());
}
}; };
let args = split_args.collect::<Vec<_>>(); match (routes.get(*command), default_route) {
let arg_count = args.len(); // Found a route
(Some(resolved), _) => (
// Try resolving with a matched command Some(resolved),
if let Some(route) = maybe_route { match split_args.next() {
let args = if args.is_empty() { &[] } else { &args[1..] }.join(" "); // Discard the first result, we found the route using the first arg
let arg_count = arg_count - 1; Some(_) => {
if check_route(route, arg_count) { let args = split_args.join(" ");
debug!("Resolved {route} with args {args}"); debug!("Resolved {} with args {}", resolved, args);
return RouteResolution::Resolved { route, args }; args
}
None => {
debug!("Resolved {} with no args", resolved);
String::new()
}
},
),
// 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);
match routes.get(route) {
Some(v) => (Some(v), args),
None => (None, String::new()),
} }
} }
// No default route and no match
// Try resolving with the default route, if it exists (None, None) => {
if let Some(route) = default_route.and_then(|route| routes.get(route)) { debug!("Failed to resolve route!");
if check_route(route, arg_count) { (None, String::new())
let args = args.join(" ");
debug!("Using default route {route} with args {args}");
return RouteResolution::Resolved { route, args };
} }
} }
RouteResolution::Unresolved
}
/// 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
}
#[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 /// 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 /// 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 doesn't exist or bunbun did not have permission to read and execute the
/// file. /// file.
fn resolve_path(path: &Path, args: &str) -> Result<HopAction<'static>, BunBunError> { fn resolve_path(path: PathBuf, args: &str) -> Result<Vec<u8>, BunBunError> {
let output = Command::new(path.canonicalize()?) let output = Command::new(path.canonicalize()?).arg(args).output()?;
.args(args.split(' '))
.output()?;
if output.status.success() { if output.status.success() {
Ok(serde_json::from_slice(&output.stdout)?) Ok(output.stdout)
} else { } else {
error!( error!(
"Program exit code for {} was not 0! Dumping standard error!", "Program exit code for {} was not 0! Dumping standard error!",
path.display(), path.display(),
); );
let error = String::from_utf8_lossy(&output.stderr); let error = String::from_utf8_lossy(&output.stderr);
Err(BunBunError::CustomProgram(error.to_string())) Err(BunBunError::CustomProgramError(error.to_string()))
} }
} }
#[cfg(test)] #[cfg(test)]
mod resolve_hop { mod resolve_hop {
use super::*; use super::*;
use anyhow::Result;
fn generate_route_result<'a>(keyword: &'a Route, args: &str) -> RouteResolution<'a> { fn generate_route_result<'a>(
RouteResolution::Resolved { keyword: &'a Route,
route: keyword, args: &str,
args: String::from(args), ) -> (Option<&'a Route>, String) {
} (Some(keyword), String::from(args))
} }
#[test] #[test]
fn empty_routes_no_default_yields_failed_hop() { fn empty_routes_no_default_yields_failed_hop() {
assert_eq!( assert_eq!(
resolve_hop("hello world", &HashMap::new(), None), resolve_hop("hello world", &HashMap::new(), &None),
RouteResolution::Unresolved (None, String::new())
); );
} }
#[test] #[test]
fn empty_routes_some_default_yields_failed_hop() { fn empty_routes_some_default_yields_failed_hop() {
assert_eq!( assert_eq!(
resolve_hop("hello world", &HashMap::new(), Some(&"google")), resolve_hop(
RouteResolution::Unresolved "hello world",
&HashMap::new(),
&Some(String::from("google"))
),
(None, String::new())
); );
} }
#[test] #[test]
fn only_default_routes_some_default_yields_default_hop() -> Result<()> { fn only_default_routes_some_default_yields_default_hop() {
let mut map: HashMap<String, Route> = HashMap::new(); let mut map: HashMap<String, Route> = HashMap::new();
map.insert("google".into(), Route::from("https://example.com")); map.insert(
assert_eq!( "google".into(),
resolve_hop("hello world", &map, Some("google")), Route::External("https://example.com".into()),
generate_route_result(&Route::from("https://example.com"), "hello world"), );
assert_eq!(
resolve_hop("hello world", &map, &Some(String::from("google"))),
generate_route_result(
&Route::External("https://example.com".into()),
"hello world"
),
); );
Ok(())
} }
#[test] #[test]
fn non_default_routes_some_default_yields_non_default_hop() -> Result<()> { fn non_default_routes_some_default_yields_non_default_hop() {
let mut map: HashMap<String, Route> = HashMap::new(); let mut map: HashMap<String, Route> = HashMap::new();
map.insert("google".into(), Route::from("https://example.com")); map.insert(
assert_eq!( "google".into(),
resolve_hop("google hello world", &map, Some("a")), Route::External("https://example.com".into()),
generate_route_result(&Route::from("https://example.com"), "hello world"), );
assert_eq!(
resolve_hop("google hello world", &map, &Some(String::from("a"))),
generate_route_result(
&Route::External("https://example.com".into()),
"hello world"
),
); );
Ok(())
} }
#[test] #[test]
fn non_default_routes_no_default_yields_non_default_hop() -> Result<()> { fn non_default_routes_no_default_yields_non_default_hop() {
let mut map: HashMap<String, Route> = HashMap::new(); let mut map: HashMap<String, Route> = HashMap::new();
map.insert("google".into(), Route::from("https://example.com")); map.insert(
assert_eq!( "google".into(),
resolve_hop("google hello world", &map, None), Route::External("https://example.com".into()),
generate_route_result(&Route::from("https://example.com"), "hello world"), );
assert_eq!(
resolve_hop("google hello world", &map, &None),
generate_route_result(
&Route::External("https://example.com".into()),
"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)] #[cfg(test)]
mod resolve_path { mod resolve_path {
use crate::error::BunBunError; use super::resolve_path;
use super::{resolve_path, HopAction};
use anyhow::Result;
use std::borrow::Cow;
use std::env::current_dir; use std::env::current_dir;
use std::io::ErrorKind; use std::path::PathBuf;
use std::path::{Path, PathBuf};
#[test] #[test]
fn invalid_path_returns_err() { fn invalid_path_returns_err() {
assert!(resolve_path(&Path::new("/bin/aaaa"), "aaaa").is_err()); assert!(resolve_path(PathBuf::from("/bin/aaaa"), "aaaa").is_err());
} }
#[test] #[test]
fn valid_path_returns_ok() { fn valid_path_returns_ok() {
assert!(resolve_path(&Path::new("/bin/echo"), r#"{"body": "a"}"#).is_ok()); assert!(resolve_path(PathBuf::from("/bin/echo"), "hello").is_ok());
} }
#[test] #[test]
fn relative_path_returns_ok() -> Result<()> { fn relative_path_returns_ok() {
// How many ".." needed to get to / // How many ".." needed to get to /
let nest_level = current_dir()?.ancestors().count() - 1; let nest_level = current_dir().unwrap().ancestors().count() - 1;
let mut rel_path = PathBuf::from("../".repeat(nest_level)); let mut rel_path = PathBuf::from("../".repeat(nest_level));
rel_path.push("./bin/echo"); rel_path.push("./bin/echo");
assert!(resolve_path(&rel_path, r#"{"body": "a"}"#).is_ok()); assert!(resolve_path(rel_path, "hello").is_ok());
Ok(())
} }
#[test] #[test]
fn no_permissions_returns_err() { fn no_permissions_returns_err() {
let result = match resolve_path(&Path::new("/root/some_exec"), "") { assert!(
Err(BunBunError::Io(e)) => e.kind() == ErrorKind::PermissionDenied, // Trying to run a command without permission
_ => false, format!(
}; "{}",
assert!(result); resolve_path(PathBuf::from("/root/some_exec"), "").unwrap_err()
)
.contains("Permission denied")
);
} }
#[test] #[test]
fn non_success_exit_code_yields_err() { fn non_success_exit_code_yields_err() {
// cat-ing a folder always returns exit code 1 // cat-ing a folder always returns exit code 1
assert!(resolve_path(&Path::new("/bin/cat"), "/").is_err()); assert!(resolve_path(PathBuf::from("/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,22 +1,17 @@
use std::borrow::Cow;
use percent_encoding::PercentEncode;
use serde::Serialize; use serde::Serialize;
pub fn query(query: PercentEncode<'_>) -> impl Serialize + '_ { pub fn query(query: String) -> impl Serialize {
#[derive(Serialize)] #[derive(Serialize)]
struct TemplateArgs<'a> { struct TemplateArgs {
query: Cow<'a, str>, query: String,
}
TemplateArgs {
query: query.into(),
} }
TemplateArgs { query }
} }
pub fn hostname(hostname: &'_ str) -> impl Serialize + '_ { pub fn hostname(hostname: String) -> impl Serialize {
#[derive(Serialize)] #[derive(Serialize)]
pub struct TemplateArgs<'a> { pub struct TemplateArgs {
pub hostname: &'a str, pub hostname: String,
} }
TemplateArgs { hostname } TemplateArgs { hostname }
} }

View file

@ -63,10 +63,10 @@
</section> </section>
</main> </main>
<p>To view a full list of commands currently available on this instance, 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>
</body>
<div class="spacer"></div> <div class="spacer"></div>
<footer> <footer>
<p>{{> bunbun_version }}</p> <p>{{> bunbun_version }}</p>
<p><a href="{{> bunbun_src }}">Source Code</a></p> <p><a href="{{> bunbun_src }}">Source Code</a></p>
</footer> </footer>
</body>
</html> </html>

View file

@ -20,7 +20,7 @@
i { color: rgba(255, 255, 255, 0.5); } i { color: rgba(255, 255, 255, 0.5); }
td, th { padding: 0 0.5rem; } td, th { padding: 0 0.5rem; }
.shortcut { text-align: right; } .shortcut { text-align: right; }
.description { text-align: left; width: 100%; } .target { text-align: left; width: 100%; }
footer { footer {
margin-top: 1rem; margin-top: 1rem;
color: #444; color: #444;
@ -29,34 +29,21 @@
</head> </head>
<body> <body>
<h1>Bunbun Command List</h1> <h1>Bunbun Command List</h1>
<p><i>To edit this list, edit your <code>bunbun.yaml</code> file.</i></p> <p><i>To edit this list, edit your <code>bunbun.toml</code> file.</i></p>
<main> <main>
{{~#each this}} {{!-- Iterate over RouteGroup --}} {{#each this}} {{!-- Iterate over RouteGroup --}}
{{~#unless this.hidden}}
<header><h2>{{this.name}}</h2><i>{{this.description}}</i></header> <header><h2>{{this.name}}</h2><i>{{this.description}}</i></header>
<table> <table>
<tr> <tr>
<th>Shortcut</th> <th>Shortcut</th>
<th class="description">Description</th> <th class="target">Target</th>
</tr> </tr>
{{~#each this.routes}} {{!-- Iterate over Route --}} {{#each this.routes}}<tr><td class="shortcut">{{@key}}</td><td class="target">{{this}}</td></tr>{{/each}}
{{~#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> </table>
{{~/unless}} {{/each}}
{{~/each}}
</main> </main>
</body>
<footer> <footer>
<p>{{> bunbun_version}}</p> <p>{{> bunbun_version}}</p>
</footer> </footer>
</body>
</html> </html>

View file

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