AkurAI Build
Menu

AkurAI-Build

public

Latest change 5ecb45c5915e3bb122d43dbaf90b4eba39484c7d - organize native templates and reserve runtime routes by Olafur Bui

//! Public Rust-native application contract.
//!
//! This module deliberately contains no dynamic loading or framework
//! compatibility. Applications are compiled into a Bunfork binary and expose
//! ordinary Rust route tables.

use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc};

use anyhow::Error;
use axum::{
    body::Bytes,
    http::{HeaderMap, Method, StatusCode, Uri},
    response::Response,
};
use serde_json::Value;
use subtle::ConstantTimeEq;

pub const MAX_FORM_BYTES: usize = 64 * 1024;
pub const MAX_FORM_FIELDS: usize = 128;
pub const MAX_FORM_KEY_BYTES: usize = 256;
pub const MAX_FORM_VALUE_BYTES: usize = 4096;

pub type Query = BTreeMap<String, Vec<String>>;
pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
pub type LoadFn = fn(RequestContext) -> BoxFuture<Result<Value, AppError>>;
pub type ActionFn = fn(RequestContext, Form) -> BoxFuture<Result<ActionResult, AppError>>;

pub trait App: Send + Sync + 'static {
    fn routes(&self) -> &'static [Route];
}

pub struct Route {
    pub pattern: &'static str,
    pub methods: &'static [Method],
    pub page: &'static str,
    pub load: Option<LoadFn>,
    pub action: Option<ActionFn>,
}

#[derive(Clone)]
pub struct RequestContext {
    pub method: Method,
    pub uri: Uri,
    pub params: BTreeMap<String, String>,
    pub query: Query,
    pub headers: HeaderMap,
    pub cookies: Cookies,
    pub body: Bytes,
    pub state: Arc<AppState>,
}

#[derive(Clone, Default)]
pub struct AppState {
    pub store: Option<Arc<dyn Store>>,
}

pub trait Store: Send + Sync {
    fn get(&self, key: &str) -> anyhow::Result<Option<Value>>;
    fn put(&self, key: &str, value: &Value) -> anyhow::Result<()>;
}

pub enum ActionResult {
    Render(Value),
    Redirect {
        location: String,
        status: StatusCode,
    },
    Response(Response),
}

pub enum AppError {
    Status {
        status: StatusCode,
        public: &'static str,
    },
    Redirect {
        location: String,
        status: StatusCode,
    },
    Internal(Error),
}

impl AppError {
    pub fn bad_request(message: &'static str) -> Self {
        Self::Status {
            status: StatusCode::BAD_REQUEST,
            public: message,
        }
    }

    pub fn unauthorized() -> Self {
        Self::Status {
            status: StatusCode::UNAUTHORIZED,
            public: "unauthorized",
        }
    }

    pub fn validation(message: &'static str) -> Self {
        Self::Status {
            status: StatusCode::UNPROCESSABLE_ENTITY,
            public: message,
        }
    }
}

#[derive(Clone, Debug, Default)]
pub struct Cookies(BTreeMap<String, String>);

impl Cookies {
    pub fn parse(headers: &HeaderMap) -> Self {
        let mut cookies = BTreeMap::new();
        let Some(value) = headers
            .get(axum::http::header::COOKIE)
            .and_then(|v| v.to_str().ok())
        else {
            return Self(cookies);
        };
        for item in value.split(';') {
            let Some((name, value)) = item.trim().split_once('=') else {
                continue;
            };
            if !name.is_empty() {
                cookies.insert(name.to_owned(), value.to_owned());
            }
        }
        Self(cookies)
    }

    pub fn get(&self, name: &str) -> Option<&str> {
        self.0.get(name).map(String::as_str)
    }
}

#[derive(Clone, Debug, Default)]
pub struct Form(BTreeMap<String, Vec<String>>);

impl Form {
    pub fn parse(body: &[u8]) -> anyhow::Result<Self> {
        anyhow::ensure!(body.len() <= MAX_FORM_BYTES, "form body exceeds 64 KiB");
        let text = std::str::from_utf8(body).map_err(|_| anyhow::anyhow!("form is not UTF-8"))?;
        let mut fields = BTreeMap::new();
        let mut field_count = 0;
        for pair in text.split('&') {
            if pair.is_empty() {
                continue;
            }
            field_count += 1;
            anyhow::ensure!(field_count <= MAX_FORM_FIELDS, "form has too many fields");
            let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
            let key = decode_component(key)?;
            let value = decode_component(value)?;
            anyhow::ensure!(!key.is_empty(), "form field name cannot be empty");
            anyhow::ensure!(
                key.len() <= MAX_FORM_KEY_BYTES,
                "form field name is too long"
            );
            anyhow::ensure!(
                value.len() <= MAX_FORM_VALUE_BYTES,
                "form field value is too long"
            );
            fields.entry(key).or_insert_with(Vec::new).push(value);
        }
        Ok(Self(fields))
    }

    pub fn get(&self, name: &str) -> Option<&str> {
        self.0.get(name).and_then(|v| v.first()).map(String::as_str)
    }
    pub fn values(&self, name: &str) -> &[String] {
        self.0.get(name).map(Vec::as_slice).unwrap_or(&[])
    }
}

pub fn constant_time_equal(left: &str, right: &str) -> bool {
    left.len() == right.len() && bool::from(left.as_bytes().ct_eq(right.as_bytes()))
}

pub fn validate_csrf(form: &Form, cookie: Option<&str>, field: &str) -> Result<(), AppError> {
    let Some(cookie) = cookie else {
        return Err(AppError::bad_request("missing CSRF cookie"));
    };
    let Some(token) = form.get(field) else {
        return Err(AppError::bad_request("missing CSRF token"));
    };
    if constant_time_equal(cookie, token) {
        Ok(())
    } else {
        Err(AppError::bad_request("invalid CSRF token"))
    }
}

fn decode_component(value: &str) -> anyhow::Result<String> {
    let value = value.replace('+', " ");
    for (index, byte) in value.as_bytes().iter().enumerate() {
        if *byte == b'%' {
            anyhow::ensure!(index + 2 < value.len(), "malformed form encoding");
            anyhow::ensure!(
                value.as_bytes()[index + 1].is_ascii_hexdigit()
                    && value.as_bytes()[index + 2].is_ascii_hexdigit(),
                "malformed form encoding"
            );
        }
    }
    percent_encoding::percent_decode_str(&value)
        .decode_utf8()
        .map(|v| v.into_owned())
        .map_err(Into::into)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn form_and_cookie_parsing_preserve_repeated_values() {
        let form = Form::parse(b"tag=one&tag=two&name=rust+web").expect("valid form");
        assert_eq!(form.values("tag"), &["one".to_owned(), "two".to_owned()]);
        assert_eq!(form.get("name"), Some("rust web"));
        let mut headers = HeaderMap::new();
        headers.insert("cookie", "sid=abc; csrf=xyz".parse().expect("header"));
        let cookies = Cookies::parse(&headers);
        assert_eq!(cookies.get("csrf"), Some("xyz"));
    }

    #[test]
    fn csrf_requires_matching_constant_time_token() {
        let form = Form::parse(b"_csrf=secret").expect("valid form");
        assert!(validate_csrf(&form, Some("secret"), "_csrf").is_ok());
        assert!(validate_csrf(&form, Some("other"), "_csrf").is_err());
        assert!(!constant_time_equal("a", "A"));
    }

    #[test]
    fn form_limits_and_malformed_encoding_fail_closed() {
        assert!(Form::parse(b"bad=%zz").is_err());
        assert!(Form::parse(&vec![b'x'; MAX_FORM_BYTES + 1]).is_err());
        let too_many = std::iter::repeat_n("x=y", MAX_FORM_FIELDS + 1)
            .collect::<Vec<_>>()
            .join("&");
        assert!(Form::parse(too_many.as_bytes()).is_err());
    }
}