use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
use ic_http_certification::{
cel::DefaultRequestCertification, DefaultCelBuilder, DefaultResponseCertification,
DefaultResponseOnlyCelExpression, HeaderField, HttpCertification, HttpCertificationPath,
HttpCertificationTree, HttpCertificationTreeEntry, HttpRequest, HttpResponse, StatusCode,
CERTIFICATE_EXPRESSION_HEADER_NAME,
};
use crate::certification::{CertificationMode, ResponseOnlyConfig};
use crate::mime::get_mime_type;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AssetEncoding {
Identity,
Gzip,
Brotli,
}
impl AssetEncoding {
pub fn as_str(&self) -> &'static str {
match self {
AssetEncoding::Identity => "identity",
AssetEncoding::Gzip => "gzip",
AssetEncoding::Brotli => "br",
}
}
}
pub struct CertifiedAsset {
pub content: Vec<u8>,
pub encodings: HashMap<AssetEncoding, Vec<u8>>,
pub content_type: String,
pub status_code: StatusCode,
pub headers: Vec<HeaderField>,
pub certification_mode: CertificationMode,
pub cel_expression: String,
pub tree_entry: HttpCertificationTreeEntry<'static>,
pub fallback_scope: Option<String>,
pub aliases: Vec<String>,
pub certified_at: u64,
pub ttl: Option<Duration>,
pub dynamic: bool,
}
impl CertifiedAsset {
pub fn is_dynamic(&self) -> bool {
self.dynamic
}
pub fn is_expired(&self, now_ns: u64) -> bool {
match self.ttl {
None => false,
Some(ttl) => {
let expiry_ns = self.certified_at.saturating_add(ttl.as_nanos() as u64);
now_ns >= expiry_ns
}
}
}
}
pub struct AssetCertificationConfig {
pub mode: CertificationMode,
pub content_type: Option<String>,
pub status_code: StatusCode,
pub headers: Vec<HeaderField>,
pub encodings: Vec<(AssetEncoding, Vec<u8>)>,
pub fallback_for: Option<String>,
pub aliases: Vec<String>,
pub certified_at: u64,
pub ttl: Option<Duration>,
pub dynamic: bool,
}
impl Default for AssetCertificationConfig {
fn default() -> Self {
Self {
mode: CertificationMode::response_only(),
content_type: None,
status_code: StatusCode::OK,
headers: vec![],
encodings: vec![],
fallback_for: None,
aliases: vec![],
certified_at: 0,
ttl: None,
dynamic: false,
}
}
}
#[derive(Debug)]
pub enum AssetRouterError {
CertificationFailed(String),
FullModeRequiresRequest,
AssetNotFound(String),
}
impl std::fmt::Display for AssetRouterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AssetRouterError::CertificationFailed(msg) => {
write!(f, "Failed to create certification: {}", msg)
}
AssetRouterError::FullModeRequiresRequest => {
write!(
f,
"Full certification mode requires a request; use certify_dynamic_asset()"
)
}
AssetRouterError::AssetNotFound(path) => {
write!(f, "Asset not found: {}", path)
}
}
}
}
impl std::error::Error for AssetRouterError {}
pub struct AssetRouter {
assets: HashMap<String, CertifiedAsset>,
aliases: HashMap<String, String>,
tree: Rc<RefCell<HttpCertificationTree>>,
fallbacks: Vec<(String, String)>,
}
impl AssetRouter {
pub fn with_tree(tree: Rc<RefCell<HttpCertificationTree>>) -> Self {
Self {
assets: HashMap::new(),
aliases: HashMap::new(),
tree,
fallbacks: Vec::new(),
}
}
pub fn root_hash(&self) -> [u8; 32] {
self.tree.borrow().root_hash()
}
pub fn contains_asset(&self, path: &str) -> bool {
let canonical = self.aliases.get(path).map(|s| s.as_str()).unwrap_or(path);
self.assets.contains_key(canonical)
}
pub fn get_asset(&self, path: &str) -> Option<&CertifiedAsset> {
let canonical = self.aliases.get(path).map(|s| s.as_str()).unwrap_or(path);
self.assets.get(canonical)
}
pub fn get_asset_mut(&mut self, path: &str) -> Option<&mut CertifiedAsset> {
let canonical = self
.aliases
.get(path)
.map(|s| s.as_str())
.unwrap_or(path)
.to_string();
self.assets.get_mut(&canonical)
}
pub fn dynamic_paths(&self) -> Vec<String> {
self.assets
.iter()
.filter(|(_, asset)| asset.is_dynamic())
.map(|(path, _)| path.clone())
.collect()
}
pub fn dynamic_paths_with_prefix(&self, prefix: &str) -> Vec<String> {
self.assets
.iter()
.filter(|(path, asset)| asset.is_dynamic() && path.starts_with(prefix))
.map(|(path, _)| path.clone())
.collect()
}
}
fn build_cel_expression_string(mode: &CertificationMode) -> String {
match mode {
CertificationMode::Skip => DefaultCelBuilder::skip_certification().to_string(),
CertificationMode::ResponseOnly(config) => {
let response_cert = build_response_certification(config);
DefaultCelBuilder::response_only_certification()
.with_response_certification(response_cert)
.build()
.to_string()
}
CertificationMode::Full(config) => {
let response_cert = build_response_certification(&config.response);
let req_refs: Vec<&str> = config.request_headers.iter().map(|s| s.as_str()).collect();
let qp_refs: Vec<&str> = config.query_params.iter().map(|s| s.as_str()).collect();
let mut builder = DefaultCelBuilder::full_certification();
if !req_refs.is_empty() {
builder = builder.with_request_headers(req_refs);
}
if !qp_refs.is_empty() {
builder = builder.with_request_query_parameters(qp_refs);
}
builder
.with_response_certification(response_cert)
.build()
.to_string()
}
}
}
fn build_response_certification<'a>(
config: &'a ResponseOnlyConfig,
) -> DefaultResponseCertification<'a> {
if config.include_headers == vec!["*"] {
let exclude_refs: Vec<&str> = config.exclude_headers.iter().map(|s| s.as_str()).collect();
DefaultResponseCertification::response_header_exclusions(exclude_refs)
} else {
let include_refs: Vec<&str> = config.include_headers.iter().map(|s| s.as_str()).collect();
DefaultResponseCertification::certified_response_headers(include_refs)
}
}
fn create_certification(
mode: &CertificationMode,
response: &HttpResponse<'_>,
) -> Result<HttpCertification, AssetRouterError> {
match mode {
CertificationMode::Skip => Ok(HttpCertification::skip()),
CertificationMode::ResponseOnly(config) => {
let response_cert = build_response_certification(config);
let expr = DefaultResponseOnlyCelExpression {
response: response_cert,
};
HttpCertification::response_only(&expr, response, None)
.map_err(|e| AssetRouterError::CertificationFailed(e.to_string()))
}
CertificationMode::Full(_) => Err(AssetRouterError::FullModeRequiresRequest),
}
}
fn create_certification_with_request(
mode: &CertificationMode,
request: &HttpRequest,
response: &HttpResponse<'_>,
) -> Result<HttpCertification, AssetRouterError> {
match mode {
CertificationMode::Skip => Ok(HttpCertification::skip()),
CertificationMode::ResponseOnly(config) => {
let response_cert = build_response_certification(config);
let expr = DefaultResponseOnlyCelExpression {
response: response_cert,
};
HttpCertification::response_only(&expr, response, None)
.map_err(|e| AssetRouterError::CertificationFailed(e.to_string()))
}
CertificationMode::Full(config) => {
let response_cert = build_response_certification(&config.response);
let req_refs: Vec<&str> = config.request_headers.iter().map(|s| s.as_str()).collect();
let qp_refs: Vec<&str> = config.query_params.iter().map(|s| s.as_str()).collect();
let expr = ic_http_certification::DefaultFullCelExpression {
request: DefaultRequestCertification::new(req_refs, qp_refs),
response: response_cert,
};
HttpCertification::full(&expr, request, response, None)
.map_err(|e| AssetRouterError::CertificationFailed(e.to_string()))
}
}
}
impl AssetRouter {
fn certify_inner(
&mut self,
path: &str,
body: Vec<u8>,
response_for_cert: Option<&HttpResponse<'static>>,
request: Option<&HttpRequest>,
config: AssetCertificationConfig,
) -> Result<(), AssetRouterError> {
let content_type = config
.content_type
.unwrap_or_else(|| get_mime_type(path).to_string());
let cel_str = build_cel_expression_string(&config.mode);
let cert_response = match response_for_cert {
None => {
let mut all_headers = vec![
("content-type".to_string(), content_type.clone()),
(
CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
cel_str.clone(),
),
];
for (name, value) in &config.headers {
all_headers.push((name.clone(), value.clone()));
}
HttpResponse::builder()
.with_status_code(config.status_code)
.with_headers(all_headers)
.with_body(body.as_slice())
.build()
}
Some(resp) => {
let mut cert_headers: Vec<HeaderField> = resp.headers().to_vec();
cert_headers.push((
CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
cel_str.clone(),
));
HttpResponse::builder()
.with_status_code(resp.status_code())
.with_headers(cert_headers)
.with_body(Cow::<[u8]>::Owned(resp.body().to_vec()))
.build()
}
};
let certification = match request {
Some(req) => create_certification_with_request(&config.mode, req, &cert_response)?,
None => create_certification(&config.mode, &cert_response)?,
};
let tree_path = if let Some(ref scope) = config.fallback_for {
HttpCertificationPath::wildcard(scope.to_string())
} else {
HttpCertificationPath::exact(path.to_string())
};
let tree_entry = HttpCertificationTreeEntry::new(tree_path, certification);
if let Some(old_asset) = self.assets.get(path) {
self.tree.borrow_mut().delete(&old_asset.tree_entry);
}
self.tree.borrow_mut().insert(&tree_entry);
let mut encodings = HashMap::new();
encodings.insert(AssetEncoding::Identity, body.clone());
for (encoding, encoded_content) in config.encodings {
encodings.insert(encoding, encoded_content);
}
let asset = CertifiedAsset {
content: body,
encodings,
content_type,
status_code: config.status_code,
headers: config.headers,
certification_mode: config.mode,
cel_expression: cel_str,
tree_entry,
fallback_scope: config.fallback_for.clone(),
aliases: config.aliases.clone(),
certified_at: config.certified_at,
ttl: config.ttl,
dynamic: config.dynamic,
};
self.assets.insert(path.to_string(), asset);
if let Some(scope) = config.fallback_for {
self.fallbacks.push((scope, path.to_string()));
self.fallbacks.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
}
for alias in config.aliases {
self.aliases.insert(alias, path.to_string());
}
Ok(())
}
pub fn certify_asset(
&mut self,
path: &str,
content: Vec<u8>,
config: AssetCertificationConfig,
) -> Result<(), AssetRouterError> {
if matches!(&config.mode, CertificationMode::Full(_)) {
return Err(AssetRouterError::FullModeRequiresRequest);
}
self.certify_inner(path, content, None, None, config)
}
pub fn certify_dynamic_asset(
&mut self,
path: &str,
request: &HttpRequest,
response: &HttpResponse<'static>,
config: AssetCertificationConfig,
) -> Result<(), AssetRouterError> {
self.certify_inner(
path,
response.body().to_vec(),
Some(response),
Some(request),
config,
)
}
pub fn serve_asset(
&self,
request: &HttpRequest,
) -> Option<(
HttpResponse<'static>,
ic_certification::HashTree,
Vec<String>,
)> {
let path = request.get_path().ok()?;
if let Some(asset) = self.assets.get(&path) {
return self.serve_matched_asset(request, &path, asset);
}
if let Some(canonical) = self.aliases.get(&path) {
if let Some(asset) = self.assets.get(canonical) {
return self.serve_matched_asset(request, &path, asset);
}
}
for (scope, fallback_path) in &self.fallbacks {
if path.starts_with(scope) {
if let Some(asset) = self.assets.get(fallback_path) {
return self.serve_matched_asset(request, &path, asset);
}
}
}
None
}
fn serve_matched_asset(
&self,
request: &HttpRequest,
request_path: &str,
asset: &CertifiedAsset,
) -> Option<(
HttpResponse<'static>,
ic_certification::HashTree,
Vec<String>,
)> {
let encoding = self.select_encoding(request, asset);
let content = asset.encodings.get(&encoding)?;
let mut headers = vec![
("content-type".to_string(), asset.content_type.clone()),
(
CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
asset.cel_expression.clone(),
),
];
for (name, value) in &asset.headers {
headers.push((name.clone(), value.clone()));
}
if encoding != AssetEncoding::Identity {
headers.push((
"content-encoding".to_string(),
encoding.as_str().to_string(),
));
}
let response = HttpResponse::builder()
.with_status_code(asset.status_code)
.with_headers(headers)
.with_body(Cow::<[u8]>::Owned(content.clone()))
.build();
let tree = self.tree.borrow();
let witness = tree.witness(&asset.tree_entry, request_path).ok()?;
let expr_path = asset.tree_entry.path.to_expr_path();
Some((response, witness, expr_path))
}
fn select_encoding(&self, request: &HttpRequest, asset: &CertifiedAsset) -> AssetEncoding {
let accept_encoding = request
.headers()
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("accept-encoding"))
.map(|(_, v)| v.as_str())
.unwrap_or("");
if accept_encoding.contains("br") && asset.encodings.contains_key(&AssetEncoding::Brotli) {
return AssetEncoding::Brotli;
}
if accept_encoding.contains("gzip") && asset.encodings.contains_key(&AssetEncoding::Gzip) {
return AssetEncoding::Gzip;
}
AssetEncoding::Identity
}
pub fn delete_asset(&mut self, path: &str) {
let canonical = self
.aliases
.remove(path)
.unwrap_or_else(|| path.to_string());
if let Some(asset) = self.assets.remove(&canonical) {
self.tree.borrow_mut().delete(&asset.tree_entry);
self.fallbacks.retain(|(_, v)| v != &canonical);
self.aliases.retain(|_, v| v != &canonical);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tree() -> Rc<RefCell<HttpCertificationTree>> {
Rc::new(RefCell::new(HttpCertificationTree::default()))
}
fn make_router() -> AssetRouter {
AssetRouter::with_tree(make_tree())
}
fn default_config() -> AssetCertificationConfig {
AssetCertificationConfig::default()
}
fn skip_config() -> AssetCertificationConfig {
AssetCertificationConfig {
mode: CertificationMode::skip(),
..Default::default()
}
}
fn full_config() -> AssetCertificationConfig {
AssetCertificationConfig {
mode: CertificationMode::authenticated(),
..Default::default()
}
}
fn make_get_request(url: &str) -> HttpRequest<'_> {
HttpRequest::get(url.to_string()).build()
}
fn make_get_request_with_encoding(url: &str, accept_encoding: &str) -> HttpRequest<'static> {
HttpRequest::get(url.to_string())
.with_headers(vec![(
"accept-encoding".to_string(),
accept_encoding.to_string(),
)])
.build()
}
fn make_response(body: &[u8]) -> HttpResponse<'static> {
HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_headers(vec![("content-type".to_string(), "text/html".to_string())])
.with_body(Cow::<[u8]>::Owned(body.to_vec()))
.build()
}
#[test]
fn certify_asset_response_only_succeeds() {
let mut router = make_router();
let result =
router.certify_asset("/index.html", b"<h1>Hello</h1>".to_vec(), default_config());
assert!(result.is_ok());
assert!(router.contains_asset("/index.html"));
let asset = router.get_asset("/index.html").unwrap();
assert_eq!(asset.content, b"<h1>Hello</h1>");
assert_eq!(asset.content_type, "text/html");
assert!(matches!(
asset.certification_mode,
CertificationMode::ResponseOnly(_)
));
}
#[test]
fn certify_asset_skip_succeeds() {
let mut router = make_router();
let result = router.certify_asset("/health", b"ok".to_vec(), skip_config());
assert!(result.is_ok());
assert!(router.contains_asset("/health"));
let asset = router.get_asset("/health").unwrap();
assert!(matches!(asset.certification_mode, CertificationMode::Skip));
}
#[test]
fn certify_asset_full_returns_error() {
let mut router = make_router();
let result = router.certify_asset("/api/data", b"{}".to_vec(), full_config());
assert!(result.is_err());
match result.unwrap_err() {
AssetRouterError::FullModeRequiresRequest => {}
other => panic!("expected FullModeRequiresRequest, got {:?}", other),
}
}
#[test]
fn certify_dynamic_asset_full_succeeds() {
let mut router = make_router();
let request = HttpRequest::get("/api/data".to_string())
.with_headers(vec![(
"authorization".to_string(),
"Bearer token123".to_string(),
)])
.build();
let response = make_response(b"{\"data\": 42}");
let config = AssetCertificationConfig {
mode: CertificationMode::authenticated(),
content_type: Some("application/json".to_string()),
..Default::default()
};
let result = router.certify_dynamic_asset("/api/data", &request, &response, config);
assert!(result.is_ok());
assert!(router.contains_asset("/api/data"));
let asset = router.get_asset("/api/data").unwrap();
assert!(matches!(
asset.certification_mode,
CertificationMode::Full(_)
));
assert_eq!(asset.content, b"{\"data\": 42}");
}
#[test]
fn certify_dynamic_asset_response_only_succeeds() {
let mut router = make_router();
let request = make_get_request("/page");
let response = make_response(b"page content");
let result = router.certify_dynamic_asset("/page", &request, &response, default_config());
assert!(
result.is_ok(),
"certify_dynamic_asset failed: {:?}",
result.err()
);
assert!(router.contains_asset("/page"));
}
#[test]
fn certify_dynamic_asset_skip_succeeds() {
let mut router = make_router();
let request = make_get_request("/health");
let response = make_response(b"ok");
let result = router.certify_dynamic_asset("/health", &request, &response, skip_config());
assert!(result.is_ok());
assert!(router.contains_asset("/health"));
}
#[test]
fn certify_asset_auto_detects_content_type() {
let mut router = make_router();
router
.certify_asset("/style.css", b"body {}".to_vec(), default_config())
.unwrap();
let asset = router.get_asset("/style.css").unwrap();
assert_eq!(asset.content_type, "text/css");
}
#[test]
fn certify_asset_uses_explicit_content_type() {
let mut router = make_router();
let config = AssetCertificationConfig {
content_type: Some("application/wasm".to_string()),
..Default::default()
};
router
.certify_asset("/module.bin", b"\0asm".to_vec(), config)
.unwrap();
let asset = router.get_asset("/module.bin").unwrap();
assert_eq!(asset.content_type, "application/wasm");
}
#[test]
fn certify_asset_registers_aliases() {
let mut router = make_router();
let config = AssetCertificationConfig {
aliases: vec!["/".to_string(), "/home".to_string()],
..Default::default()
};
router
.certify_asset("/index.html", b"<h1>Home</h1>".to_vec(), config)
.unwrap();
assert!(router.contains_asset("/index.html"));
assert!(router.contains_asset("/"));
assert!(router.contains_asset("/home"));
let a1 = router.get_asset("/index.html").unwrap() as *const _;
let a2 = router.get_asset("/").unwrap() as *const _;
let a3 = router.get_asset("/home").unwrap() as *const _;
assert_eq!(a1, a2);
assert_eq!(a1, a3);
}
#[test]
fn certify_asset_registers_fallback() {
let mut router = make_router();
let config = AssetCertificationConfig {
fallback_for: Some("/".to_string()),
..Default::default()
};
router
.certify_asset("/index.html", b"<h1>SPA</h1>".to_vec(), config)
.unwrap();
assert_eq!(router.fallbacks.len(), 1);
assert_eq!(
router.fallbacks[0],
("/".to_string(), "/index.html".to_string())
);
}
#[test]
fn certify_asset_stores_encodings() {
let mut router = make_router();
let config = AssetCertificationConfig {
encodings: vec![
(AssetEncoding::Gzip, b"gzip-content".to_vec()),
(AssetEncoding::Brotli, b"brotli-content".to_vec()),
],
..Default::default()
};
router
.certify_asset("/index.html", b"<h1>Hello</h1>".to_vec(), config)
.unwrap();
let asset = router.get_asset("/index.html").unwrap();
assert_eq!(asset.encodings.len(), 3); assert_eq!(
asset.encodings.get(&AssetEncoding::Identity).unwrap(),
b"<h1>Hello</h1>"
);
assert_eq!(
asset.encodings.get(&AssetEncoding::Gzip).unwrap(),
b"gzip-content"
);
assert_eq!(
asset.encodings.get(&AssetEncoding::Brotli).unwrap(),
b"brotli-content"
);
}
#[test]
fn certify_asset_stores_certified_at_and_ttl() {
let mut router = make_router();
let config = AssetCertificationConfig {
certified_at: 1_000_000,
ttl: Some(Duration::from_secs(3600)),
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
assert_eq!(asset.certified_at, 1_000_000);
assert_eq!(asset.ttl, Some(Duration::from_secs(3600)));
}
#[test]
fn serve_asset_exact_match() {
let mut router = make_router();
router
.certify_asset("/index.html", b"<h1>Hello</h1>".to_vec(), default_config())
.unwrap();
let request = make_get_request("/index.html");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _witness, expr_path) = result.unwrap();
assert_eq!(response.status_code(), StatusCode::OK);
assert_eq!(response.body(), b"<h1>Hello</h1>");
assert!(!expr_path.is_empty());
}
#[test]
fn serve_asset_alias_resolves() {
let mut router = make_router();
let config = AssetCertificationConfig {
aliases: vec!["/".to_string()],
..Default::default()
};
router
.certify_asset("/index.html", b"<h1>Home</h1>".to_vec(), config)
.unwrap();
let request = make_get_request("/");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _, _) = result.unwrap();
assert_eq!(response.body(), b"<h1>Home</h1>");
}
#[test]
fn serve_asset_fallback_match() {
let mut router = make_router();
let config = AssetCertificationConfig {
fallback_for: Some("/".to_string()),
..Default::default()
};
router
.certify_asset("/index.html", b"<h1>SPA</h1>".to_vec(), config)
.unwrap();
let request = make_get_request("/about");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _, _) = result.unwrap();
assert_eq!(response.body(), b"<h1>SPA</h1>");
}
#[test]
fn serve_asset_longest_prefix_fallback_wins() {
let mut router = make_router();
let config1 = AssetCertificationConfig {
fallback_for: Some("/".to_string()),
..Default::default()
};
router
.certify_asset("/404.html", b"Not Found".to_vec(), config1)
.unwrap();
let config2 = AssetCertificationConfig {
fallback_for: Some("/app".to_string()),
..Default::default()
};
router
.certify_asset("/app/index.html", b"App SPA".to_vec(), config2)
.unwrap();
let request = make_get_request("/app/dashboard");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _, _) = result.unwrap();
assert_eq!(response.body(), b"App SPA");
let request = make_get_request("/about");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _, _) = result.unwrap();
assert_eq!(response.body(), b"Not Found");
}
#[test]
fn serve_asset_no_match_returns_none() {
let mut router = make_router();
router
.certify_asset("/index.html", b"content".to_vec(), default_config())
.unwrap();
let request = make_get_request("/missing");
let result = router.serve_asset(&request);
assert!(result.is_none());
}
#[test]
fn serve_asset_skip_has_valid_witness_and_expr_path() {
let mut router = make_router();
router
.certify_asset("/health", b"ok".to_vec(), skip_config())
.unwrap();
let request = make_get_request("/health");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _witness, expr_path) = result.unwrap();
assert_eq!(response.body(), b"ok");
assert!(!expr_path.is_empty());
}
#[test]
fn serve_asset_encoding_negotiation_brotli_preferred() {
let mut router = make_router();
let config = AssetCertificationConfig {
encodings: vec![
(AssetEncoding::Gzip, b"gzip-content".to_vec()),
(AssetEncoding::Brotli, b"br-content".to_vec()),
],
..Default::default()
};
router
.certify_asset("/index.html", b"raw".to_vec(), config)
.unwrap();
let request = make_get_request_with_encoding("/index.html", "gzip, br");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _, _) = result.unwrap();
assert_eq!(response.body(), b"br-content");
let ce = response
.headers()
.iter()
.find(|(k, _)| k == "content-encoding")
.map(|(_, v)| v.as_str());
assert_eq!(ce, Some("br"));
}
#[test]
fn serve_asset_encoding_negotiation_gzip_fallback() {
let mut router = make_router();
let config = AssetCertificationConfig {
encodings: vec![(AssetEncoding::Gzip, b"gzip-content".to_vec())],
..Default::default()
};
router
.certify_asset("/index.html", b"raw".to_vec(), config)
.unwrap();
let request = make_get_request_with_encoding("/index.html", "gzip");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _, _) = result.unwrap();
assert_eq!(response.body(), b"gzip-content");
}
#[test]
fn serve_asset_encoding_negotiation_identity_fallback() {
let mut router = make_router();
let config = AssetCertificationConfig {
encodings: vec![(AssetEncoding::Brotli, b"br-content".to_vec())],
..Default::default()
};
router
.certify_asset("/index.html", b"raw".to_vec(), config)
.unwrap();
let request = make_get_request("/index.html");
let result = router.serve_asset(&request);
assert!(result.is_some());
let (response, _, _) = result.unwrap();
assert_eq!(response.body(), b"raw");
let ce = response
.headers()
.iter()
.find(|(k, _)| k == "content-encoding");
assert!(ce.is_none());
}
#[test]
fn serve_asset_includes_cel_expression_header() {
let mut router = make_router();
router
.certify_asset("/index.html", b"content".to_vec(), default_config())
.unwrap();
let request = make_get_request("/index.html");
let (response, _, _) = router.serve_asset(&request).unwrap();
let cel_header = response
.headers()
.iter()
.find(|(k, _)| k == CERTIFICATE_EXPRESSION_HEADER_NAME)
.map(|(_, v)| v.clone());
assert!(cel_header.is_some());
assert!(!cel_header.unwrap().is_empty());
}
#[test]
fn delete_asset_removes_asset_and_tree_entry() {
let mut router = make_router();
router
.certify_asset("/page", b"content".to_vec(), default_config())
.unwrap();
assert!(router.contains_asset("/page"));
router.delete_asset("/page");
assert!(!router.contains_asset("/page"));
assert!(router.get_asset("/page").is_none());
}
#[test]
fn delete_asset_removes_aliases() {
let mut router = make_router();
let config = AssetCertificationConfig {
aliases: vec!["/home".to_string()],
..Default::default()
};
router
.certify_asset("/index.html", b"content".to_vec(), config)
.unwrap();
assert!(router.contains_asset("/home"));
router.delete_asset("/index.html");
assert!(!router.contains_asset("/index.html"));
assert!(!router.contains_asset("/home"));
}
#[test]
fn delete_asset_removes_fallback() {
let mut router = make_router();
let config = AssetCertificationConfig {
fallback_for: Some("/".to_string()),
..Default::default()
};
router
.certify_asset("/index.html", b"content".to_vec(), config)
.unwrap();
assert_eq!(router.fallbacks.len(), 1);
router.delete_asset("/index.html");
assert!(router.fallbacks.is_empty());
}
#[test]
fn delete_asset_via_alias_resolves_and_removes_canonical() {
let mut router = make_router();
let config = AssetCertificationConfig {
aliases: vec!["/home".to_string()],
..Default::default()
};
router
.certify_asset("/index.html", b"content".to_vec(), config)
.unwrap();
router.delete_asset("/home");
assert!(!router.contains_asset("/index.html"));
assert!(!router.contains_asset("/home"));
}
#[test]
fn delete_asset_nonexistent_is_noop() {
let mut router = make_router();
router.delete_asset("/nonexistent");
assert!(!router.contains_asset("/nonexistent"));
}
#[test]
fn get_asset_via_canonical_and_alias() {
let mut router = make_router();
let config = AssetCertificationConfig {
aliases: vec!["/home".to_string()],
..Default::default()
};
router
.certify_asset("/index.html", b"content".to_vec(), config)
.unwrap();
assert!(router.get_asset("/index.html").is_some());
assert!(router.get_asset("/home").is_some());
}
#[test]
fn get_asset_nonexistent_returns_none() {
let router = make_router();
assert!(router.get_asset("/nonexistent").is_none());
}
#[test]
fn root_hash_changes_after_certify() {
let tree = make_tree();
let mut router = AssetRouter::with_tree(tree);
let hash_before = router.root_hash();
router
.certify_asset("/page", b"content".to_vec(), default_config())
.unwrap();
let hash_after = router.root_hash();
assert_ne!(hash_before, hash_after);
}
#[test]
fn root_hash_changes_after_delete() {
let tree = make_tree();
let mut router = AssetRouter::with_tree(tree);
router
.certify_asset("/page", b"content".to_vec(), default_config())
.unwrap();
let hash_with_asset = router.root_hash();
router.delete_asset("/page");
let hash_after_delete = router.root_hash();
assert_ne!(hash_with_asset, hash_after_delete);
}
#[test]
fn recertification_replaces_old_hash() {
let tree = make_tree();
let mut router = AssetRouter::with_tree(tree);
router
.certify_asset("/page", b"v1".to_vec(), default_config())
.unwrap();
let hash_v1 = router.root_hash();
router.delete_asset("/page");
router
.certify_asset("/page", b"v2".to_vec(), default_config())
.unwrap();
let hash_v2 = router.root_hash();
assert_ne!(hash_v1, hash_v2);
}
#[test]
fn mode_switching_certify_delete_recertify() {
let tree = make_tree();
let mut router = AssetRouter::with_tree(tree);
router
.certify_asset("/page", b"content".to_vec(), default_config())
.unwrap();
assert!(matches!(
router.get_asset("/page").unwrap().certification_mode,
CertificationMode::ResponseOnly(_)
));
router.delete_asset("/page");
router
.certify_asset("/page", b"content".to_vec(), skip_config())
.unwrap();
assert!(matches!(
router.get_asset("/page").unwrap().certification_mode,
CertificationMode::Skip
));
}
#[test]
fn is_dynamic_true_when_marked_dynamic() {
let mut router = make_router();
let config = AssetCertificationConfig {
ttl: Some(Duration::from_secs(3600)),
dynamic: true,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
assert!(router.get_asset("/page").unwrap().is_dynamic());
}
#[test]
fn is_dynamic_false_when_not_marked_dynamic() {
let mut router = make_router();
router
.certify_asset("/page", b"content".to_vec(), default_config())
.unwrap();
assert!(!router.get_asset("/page").unwrap().is_dynamic());
}
#[test]
fn is_dynamic_true_without_ttl() {
let mut router = make_router();
let config = AssetCertificationConfig {
ttl: None,
dynamic: true,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
assert!(router.get_asset("/page").unwrap().is_dynamic());
}
#[test]
fn is_expired_respects_certified_at_and_ttl() {
let mut router = make_router();
let one_hour_ns: u64 = 3_600_000_000_000;
let config = AssetCertificationConfig {
certified_at: 1_000_000,
ttl: Some(Duration::from_secs(3600)),
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
assert!(!asset.is_expired(1_000_000 + one_hour_ns - 1));
assert!(asset.is_expired(1_000_000 + one_hour_ns));
assert!(asset.is_expired(1_000_000 + one_hour_ns + 1));
}
#[test]
fn is_expired_static_assets_never_expire() {
let mut router = make_router();
let config = AssetCertificationConfig {
certified_at: 1_000_000,
ttl: None,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
assert!(!asset.is_expired(u64::MAX));
assert!(!asset.is_expired(0));
}
#[test]
fn asset_encoding_as_str() {
assert_eq!(AssetEncoding::Identity.as_str(), "identity");
assert_eq!(AssetEncoding::Gzip.as_str(), "gzip");
assert_eq!(AssetEncoding::Brotli.as_str(), "br");
}
#[test]
fn asset_certification_config_default() {
let config = AssetCertificationConfig::default();
assert!(matches!(config.mode, CertificationMode::ResponseOnly(_)));
assert!(config.content_type.is_none());
assert!(config.headers.is_empty());
assert!(config.encodings.is_empty());
assert!(config.fallback_for.is_none());
assert!(config.aliases.is_empty());
assert_eq!(config.certified_at, 0);
assert!(config.ttl.is_none());
}
#[test]
fn asset_router_error_display() {
let e = AssetRouterError::CertificationFailed("bad".into());
assert!(e.to_string().contains("bad"));
let e = AssetRouterError::FullModeRequiresRequest;
assert!(e.to_string().contains("certify_dynamic_asset"));
let e = AssetRouterError::AssetNotFound("/missing".into());
assert!(e.to_string().contains("/missing"));
}
#[test]
fn new_router_is_empty() {
let router = make_router();
assert!(!router.contains_asset("/anything"));
assert!(router.get_asset("/anything").is_none());
}
#[test]
fn get_asset_mut_works() {
let mut router = make_router();
router
.certify_asset("/page", b"original".to_vec(), default_config())
.unwrap();
let asset = router.get_asset_mut("/page").unwrap();
asset.certified_at = 999;
assert_eq!(router.get_asset("/page").unwrap().certified_at, 999);
}
#[test]
fn build_cel_expression_string_skip() {
let mode = CertificationMode::skip();
let cel = build_cel_expression_string(&mode);
assert!(!cel.is_empty());
}
#[test]
fn build_cel_expression_string_response_only() {
let mode = CertificationMode::response_only();
let cel = build_cel_expression_string(&mode);
assert!(!cel.is_empty());
assert!(cel.contains("certification"));
}
#[test]
fn build_cel_expression_string_full() {
let mode = CertificationMode::authenticated();
let cel = build_cel_expression_string(&mode);
assert!(!cel.is_empty());
assert!(cel.contains("certification"));
}
#[test]
fn certify_asset_with_additional_headers() {
let mut router = make_router();
let config = AssetCertificationConfig {
headers: vec![("x-custom".to_string(), "value".to_string())],
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
assert_eq!(asset.headers.len(), 1);
assert_eq!(
asset.headers[0],
("x-custom".to_string(), "value".to_string())
);
}
#[test]
fn serve_asset_includes_additional_headers() {
let mut router = make_router();
let config = AssetCertificationConfig {
headers: vec![("x-custom".to_string(), "value".to_string())],
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let request = make_get_request("/page");
let (response, _, _) = router.serve_asset(&request).unwrap();
let custom = response
.headers()
.iter()
.find(|(k, _)| k == "x-custom")
.map(|(_, v)| v.as_str());
assert_eq!(custom, Some("value"));
}
#[test]
fn certify_asset_duplicate_path_replaces() {
let mut router = make_router();
router
.certify_asset("/page", b"v1".to_vec(), default_config())
.unwrap();
let asset_v1 = router.get_asset("/page").unwrap();
assert_eq!(asset_v1.content, b"v1");
router
.certify_asset("/page", b"v2".to_vec(), default_config())
.unwrap();
let asset_v2 = router.get_asset("/page").unwrap();
assert_eq!(asset_v2.content, b"v2");
let request = make_get_request("/page");
let (response, _, _) = router.serve_asset(&request).unwrap();
assert_eq!(response.body(), b"v2");
}
#[test]
fn delete_nonexistent_asset_is_noop() {
let mut router = make_router();
router
.certify_asset("/exists", b"data".to_vec(), default_config())
.unwrap();
router.delete_asset("/does-not-exist");
assert!(router.contains_asset("/exists"));
assert!(!router.contains_asset("/does-not-exist"));
}
}