mod finger;
mod gemini;
mod gopher;
mod guppy;
mod misfin;
mod nex;
mod plain;
mod spartan;
mod titan;
mod tls;
mod tofu;
pub mod parse;
pub use misfin::{send as misfin_send, ClientIdentity, MISFIN_PORT};
pub use titan::upload as titan_upload;
pub use tofu::{set_trust_store, InMemoryTofu, PermissiveTofu, TofuStore};
pub use url::Url;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scheme {
Gemini,
Gopher,
Finger,
Spartan,
Nex,
Guppy,
Titan,
}
impl Scheme {
pub fn default_port(self) -> u16 {
match self {
Scheme::Gemini => 1965,
Scheme::Gopher => 70,
Scheme::Finger => 79,
Scheme::Spartan => 300,
Scheme::Nex => 1900,
Scheme::Guppy => 6775,
Scheme::Titan => 1965,
}
}
pub fn parse(scheme: &str) -> Option<Scheme> {
match scheme {
"gemini" => Some(Scheme::Gemini),
"gopher" => Some(Scheme::Gopher),
"finger" => Some(Scheme::Finger),
"spartan" => Some(Scheme::Spartan),
"nex" => Some(Scheme::Nex),
"guppy" => Some(Scheme::Guppy),
"titan" => Some(Scheme::Titan),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Success,
Input,
Redirect,
Failure,
CertRequired,
}
#[derive(Clone, Debug)]
pub struct Response {
pub url: Url,
pub status: Status,
pub raw_status: Option<u8>,
pub meta: String,
pub body: Vec<u8>,
}
impl Response {
pub fn mime(&self) -> Option<&str> {
if self.status != Status::Success {
return None;
}
let mime = self.meta.split(';').next().unwrap_or("").trim();
(!mime.is_empty()).then_some(mime)
}
}
#[derive(Clone, Debug)]
pub enum Error {
UnsupportedScheme(String),
BadUrl(String),
Connect(String),
Io(String),
Protocol(String),
Timeout,
CertificateChanged {
host: String,
pinned: String,
seen: String,
},
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::UnsupportedScheme(s) => write!(f, "unsupported scheme: {s}"),
Error::BadUrl(s) => write!(f, "bad URL: {s}"),
Error::Connect(s) => write!(f, "connect: {s}"),
Error::Io(s) => write!(f, "io: {s}"),
Error::Protocol(s) => write!(f, "protocol: {s}"),
Error::Timeout => write!(f, "fetch timed out"),
Error::CertificateChanged { host, pinned, seen } => write!(
f,
"certificate for {host} changed (possible MITM); pinned {pinned}, saw {seen}"
),
}
}
}
impl std::error::Error for Error {}
pub async fn fetch(url: &str) -> Result<Response, Error> {
let parsed = Url::parse(url).map_err(|e| Error::BadUrl(e.to_string()))?;
fetch_url(&parsed).await
}
pub async fn fetch_url(url: &Url) -> Result<Response, Error> {
let started = std::time::Instant::now();
let result = fetch_url_inner(url).await;
let elapsed_ms = started.elapsed().as_millis();
let scheme = url.scheme();
match &result {
Ok(response) => {
tracing::debug!(
target: "errand",
url = %url,
scheme,
status = ?response.status,
raw_status = ?response.raw_status,
byte_len = response.body.len(),
elapsed_ms,
"smolweb fetch complete"
);
}
Err(error) => {
tracing::warn!(
target: "errand",
url = %url,
scheme,
error = %error,
elapsed_ms,
"smolweb fetch failed"
);
}
}
result
}
async fn fetch_url_inner(url: &Url) -> Result<Response, Error> {
match Scheme::parse(url.scheme()) {
Some(Scheme::Gemini) => gemini::fetch(url).await,
Some(Scheme::Gopher) => gopher::fetch(url).await,
Some(Scheme::Finger) => finger::fetch(url).await,
Some(Scheme::Spartan) => spartan::fetch(url).await,
Some(Scheme::Nex) => nex::fetch(url).await,
Some(Scheme::Guppy) => guppy::fetch(url).await,
Some(Scheme::Titan) => titan::fetch(url).await,
None => Err(Error::UnsupportedScheme(url.scheme().to_string())),
}
}
pub async fn fetch_timeout(url: &str, timeout: std::time::Duration) -> Result<Response, Error> {
let parsed = Url::parse(url).map_err(|e| Error::BadUrl(e.to_string()))?;
fetch_url_timeout(&parsed, timeout).await
}
pub async fn fetch_url_timeout(url: &Url, timeout: std::time::Duration) -> Result<Response, Error> {
tokio::time::timeout(timeout, fetch_url(url))
.await
.map_err(|_| Error::Timeout)?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scheme_round_trips_and_ports() {
assert_eq!(Scheme::parse("gemini"), Some(Scheme::Gemini));
assert_eq!(Scheme::parse("spartan").map(Scheme::default_port), Some(300));
assert_eq!(Scheme::parse("https"), None);
}
#[test]
fn new_schemes_round_trip() {
assert_eq!(Scheme::parse("nex").map(Scheme::default_port), Some(1900));
assert_eq!(Scheme::parse("guppy").map(Scheme::default_port), Some(6775));
assert_eq!(Scheme::parse("titan").map(Scheme::default_port), Some(1965));
}
#[test]
fn mime_strips_params_and_gates_on_success() {
let mut r = Response {
url: Url::parse("gemini://x/").unwrap(),
status: Status::Success,
raw_status: Some(20),
meta: "text/gemini; charset=utf-8".into(),
body: Vec::new(),
};
assert_eq!(r.mime(), Some("text/gemini"));
r.status = Status::Failure;
assert_eq!(r.mime(), None, "non-success has no mime");
}
#[tokio::test]
async fn http_is_not_routed() {
let err = fetch("https://example.com/").await.unwrap_err();
assert!(matches!(err, Error::UnsupportedScheme(s) if s == "https"));
}
#[test]
fn every_smolweb_scheme_is_recognized() {
for s in ["gemini", "gopher", "finger", "spartan", "nex", "guppy", "titan"] {
assert!(Scheme::parse(s).is_some(), "{s} should route");
}
}
#[test]
fn timeout_error_displays() {
assert_eq!(Error::Timeout.to_string(), "fetch timed out");
}
}