use super::super::acl::{AclEntry, Role};
use super::super::error::AppError;
use super::super::store::Store;
use super::store::get_domain;
use crate::method::{method_by_name, parse_did_method};
pub async fn assert_host_is_active_domain(store: &Store, host: &str) -> Result<(), AppError> {
let entry = get_domain(store, host).await?.ok_or_else(|| {
AppError::Validation(format!(
"did host '{host}' is not a configured domain on this server"
))
})?;
if !entry.status.is_active() {
return Err(AppError::Validation(format!(
"did host '{host}' is configured but disabled"
)));
}
Ok(())
}
pub fn assert_acl_allows_host(acl_entry: &AclEntry, host: &str) -> Result<(), AppError> {
if matches!(acl_entry.role, Role::Admin | Role::Service) {
return Ok(());
}
if acl_entry.domains.allows(host) {
return Ok(());
}
Err(AppError::Forbidden(format!(
"caller is not authorised to operate on domain '{host}'"
)))
}
pub fn extract_did_host(did: &str) -> Result<String, AppError> {
let method_name = parse_did_method(did)
.map_err(|e| AppError::Validation(format!("malformed DID identifier '{did}': {e}")))?;
let method = method_by_name(method_name).ok_or_else(|| {
AppError::Validation(format!(
"DID method '{method_name}' is not supported by this server"
))
})?;
let parsed = method.parse_identifier(did).map_err(|e| {
AppError::Validation(format!("could not parse DID identifier '{did}': {e}"))
})?;
Ok(percent_decode_to_string(&parsed.domain).unwrap_or(parsed.domain))
}
pub async fn assert_did_host_allowed(
store: &Store,
acl_entry: &AclEntry,
did: &str,
) -> Result<(), AppError> {
let host = extract_did_host(did)?;
assert_host_is_active_domain(store, &host).await?;
assert_acl_allows_host(acl_entry, &host)?;
Ok(())
}
pub fn assert_request_host_matches_did(request_host: &str, did_id: &str) -> Result<(), AppError> {
let embedded_host = extract_did_host(did_id).map_err(|_| {
AppError::NotFound(format!("did identifier unparseable: {did_id}"))
})?;
if request_host.eq_ignore_ascii_case(&embedded_host) {
return Ok(());
}
tracing::warn!(
request_host = %request_host,
did_host = %embedded_host,
did_id = %did_id,
"resolution rejected: request Host does not match embedded did.host"
);
Err(AppError::NotFound(format!(
"no DID resolvable at host {request_host}"
)))
}
fn percent_decode_to_string(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
return None;
}
let hi = (bytes[i + 1] as char).to_digit(16)?;
let lo = (bytes[i + 2] as char).to_digit(16)?;
out.push(((hi << 4) | lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
pub async fn assert_domain_active_for_resolution(
store: &Store,
host: &str,
) -> Result<(), AppError> {
let entry = get_domain(store, host).await?.ok_or_else(|| {
AppError::NotFound(format!("domain not configured: {host}"))
})?;
if entry.status.is_active() {
return Ok(());
}
Err(AppError::DomainDisabled {
domain: host.to_string(),
message: None,
})
}
pub async fn assert_did_host_allowed_when_domains_configured(
store: &Store,
acl_entry: &AclEntry,
did: &str,
) -> Result<(), AppError> {
use crate::server::store::KS_DOMAINS;
let ks = store.keyspace(KS_DOMAINS)?;
let any_domain = ks
.prefix_iter_raw(b"".to_vec())
.await?
.into_iter()
.next()
.is_some();
if !any_domain {
tracing::warn!(
did = %did,
"domains keyspace is empty — skipping host-vs-domain safety check. \
Configure `[hosting] bootstrap_domains` or create a domain via the \
admin API to enable enforcement."
);
return Ok(());
}
assert_did_host_allowed(store, acl_entry, did).await
}
pub async fn assert_resolution_allowed(
store: &Store,
request_host: &str,
did_id: &str,
) -> Result<(), AppError> {
use crate::server::store::KS_DOMAINS;
let ks = store.keyspace(KS_DOMAINS)?;
let any_domain = ks
.prefix_iter_raw(b"".to_vec())
.await?
.into_iter()
.next()
.is_some();
if !any_domain {
tracing::warn!(
did = %did_id,
request_host = %request_host,
"domains keyspace is empty — skipping resolve-side safety check"
);
return Ok(());
}
assert_request_host_matches_did(request_host, did_id)?;
assert_domain_active_for_resolution(store, request_host).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::acl::AclEntry;
use crate::server::config::StoreConfig;
use crate::server::domain::scope::DomainScope;
use crate::server::domain::store::{create_domain, set_default_domain};
use crate::server::domain::types::{DomainEntry, DomainStatus, DomainUrlScheme};
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
fn entry(name: &str, status: DomainStatus) -> DomainEntry {
DomainEntry {
name: name.into(),
label: None,
scheme: DomainUrlScheme::Https,
status,
created_at: 0,
default_domain: false,
branding: None,
witnesses: None,
watchers: None,
quota: None,
well_known_enabled: false,
disabled_at: None,
purge_at: None,
}
}
fn acl(role: Role, scope: DomainScope) -> AclEntry {
AclEntry {
did: "did:example:caller".into(),
role,
label: None,
created_at: 0,
max_total_size: None,
max_did_count: None,
domains: scope,
}
}
#[test]
fn extract_host_webvh() {
let host = extract_did_host("did:webvh:QmABC:example.com:user1").unwrap();
assert_eq!(host, "example.com");
}
#[test]
fn extract_host_webvh_with_port_encoded() {
let host = extract_did_host("did:webvh:QmABC:example.com%3A8085:user1").unwrap();
assert_eq!(host, "example.com:8085");
}
#[test]
fn extract_host_webvh_with_port_encoded_localhost() {
let host = extract_did_host("did:webvh:QmABC:localhost%3A8534:user1").unwrap();
assert_eq!(host, "localhost:8534");
}
#[test]
fn extract_host_webvh_with_port_encoded_lowercase_hex() {
let host = extract_did_host("did:webvh:QmABC:localhost%3a8534:user1").unwrap();
assert_eq!(host, "localhost:8534");
}
#[test]
fn extract_host_webvh_plain_domain_unchanged() {
let host = extract_did_host("did:webvh:QmABC:hosting.example.com:user1").unwrap();
assert_eq!(host, "hosting.example.com");
}
#[test]
fn extract_host_unknown_method_rejects() {
let err = extract_did_host("did:webs:scid:example.com:user1").expect_err("unknown method");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn extract_host_web() {
let host = extract_did_host("did:web:example.com:user1").unwrap();
assert_eq!(host, "example.com");
}
#[test]
fn extract_host_web_no_path() {
let host = extract_did_host("did:web:example.com").unwrap();
assert_eq!(host, "example.com");
}
#[test]
fn extract_host_malformed_rejects() {
for bad in ["not-a-did", "did:", "did::body", "did:webvh:onlyone"] {
let err = extract_did_host(bad).expect_err(bad);
assert!(matches!(err, AppError::Validation(_)));
}
}
#[tokio::test]
async fn host_must_be_configured_domain() {
let store = fjall_store().await;
let err = assert_host_is_active_domain(&store, "missing.example")
.await
.expect_err("must reject missing domain");
assert!(matches!(err, AppError::Validation(_)));
assert!(err.to_string().contains("not a configured domain"));
}
#[tokio::test]
async fn host_must_be_active_not_disabled() {
let store = fjall_store().await;
create_domain(&store, &entry("disabled.example", DomainStatus::Disabled))
.await
.unwrap();
let err = assert_host_is_active_domain(&store, "disabled.example")
.await
.expect_err("must reject disabled");
assert!(matches!(err, AppError::Validation(_)));
assert!(err.to_string().contains("disabled"));
}
#[tokio::test]
async fn active_domain_passes() {
let store = fjall_store().await;
create_domain(&store, &entry("active.example", DomainStatus::Active))
.await
.unwrap();
assert!(
assert_host_is_active_domain(&store, "active.example")
.await
.is_ok()
);
}
#[test]
fn admin_short_circuits_regardless_of_scope() {
let e = acl(
Role::Admin,
DomainScope::Allowed {
domains: vec!["a.example".into()],
},
);
assert!(assert_acl_allows_host(&e, "b.example").is_ok());
}
#[test]
fn service_short_circuits_regardless_of_scope() {
let e = acl(
Role::Service,
DomainScope::Allowed {
domains: vec!["a.example".into()],
},
);
assert!(assert_acl_allows_host(&e, "b.example").is_ok());
}
#[test]
fn owner_all_scope_allows_anything() {
let e = acl(Role::Owner, DomainScope::All);
assert!(assert_acl_allows_host(&e, "any.example").is_ok());
}
#[test]
fn owner_allowed_scope_membership_only() {
let e = acl(
Role::Owner,
DomainScope::Allowed {
domains: vec!["a.example".into(), "b.example".into()],
},
);
assert!(assert_acl_allows_host(&e, "a.example").is_ok());
assert!(assert_acl_allows_host(&e, "b.example").is_ok());
let err = assert_acl_allows_host(&e, "c.example").expect_err("not in scope");
assert!(matches!(err, AppError::Forbidden(_)));
}
#[test]
fn forbidden_error_does_not_leak_allowed_list() {
let e = acl(
Role::Owner,
DomainScope::Allowed {
domains: vec!["secret-tenant.example".into()],
},
);
let err = assert_acl_allows_host(&e, "other.example").expect_err("not in scope");
let s = err.to_string();
assert!(s.contains("other.example"));
assert!(!s.contains("secret-tenant.example"));
}
#[test]
fn request_host_matches_did_happy_path() {
assert!(
assert_request_host_matches_did("example.com", "did:webvh:Q1:example.com:user1")
.is_ok()
);
}
#[test]
fn request_host_matches_did_case_insensitive() {
assert!(
assert_request_host_matches_did("Example.COM", "did:webvh:Q1:example.com:user1")
.is_ok()
);
}
#[test]
fn request_host_matches_did_mismatch_yields_404() {
let err = assert_request_host_matches_did(
"domain-b.example",
"did:webvh:Q1:domain-a.example:user1",
)
.expect_err("mismatch must reject");
assert!(matches!(err, AppError::NotFound(_)));
}
#[test]
fn request_host_matches_did_unparseable_yields_404() {
let err = assert_request_host_matches_did("example.com", "garbage")
.expect_err("unparseable did must reject");
assert!(matches!(err, AppError::NotFound(_)));
}
#[test]
fn request_host_matches_did_with_encoded_port() {
assert!(
assert_request_host_matches_did("localhost:8534", "did:webvh:Q1:localhost%3A8534")
.is_ok()
);
assert!(
assert_request_host_matches_did("example.com:8443", "did:web:example.com%3A8443:user1")
.is_ok()
);
}
#[test]
fn request_host_matches_did_with_encoded_port_mismatch_yields_404() {
let err =
assert_request_host_matches_did("localhost:9999", "did:webvh:Q1:localhost%3A8534")
.expect_err("port mismatch must reject");
assert!(matches!(err, AppError::NotFound(_)));
}
#[test]
fn percent_decode_helpers() {
assert_eq!(
percent_decode_to_string("localhost%3A8534").as_deref(),
Some("localhost:8534")
);
assert_eq!(
percent_decode_to_string("localhost%3a8534").as_deref(),
Some("localhost:8534")
);
assert_eq!(
percent_decode_to_string("example.com").as_deref(),
Some("example.com")
);
assert_eq!(
percent_decode_to_string("a%2Fb%3Ac").as_deref(),
Some("a/b:c")
);
assert_eq!(percent_decode_to_string("bad%"), None);
assert_eq!(percent_decode_to_string("bad%3"), None);
assert_eq!(percent_decode_to_string("bad%ZZ"), None);
}
#[tokio::test]
async fn resolution_against_disabled_domain_yields_503() {
let store = fjall_store().await;
create_domain(&store, &entry("disabled.example", DomainStatus::Disabled))
.await
.unwrap();
let err = assert_domain_active_for_resolution(&store, "disabled.example")
.await
.expect_err("disabled must reject");
match err {
AppError::DomainDisabled { domain, .. } => {
assert_eq!(domain, "disabled.example");
}
other => panic!("expected DomainDisabled, got {other:?}"),
}
}
#[tokio::test]
async fn resolution_against_missing_domain_yields_404() {
let store = fjall_store().await;
let err = assert_domain_active_for_resolution(&store, "missing.example")
.await
.expect_err("missing must reject");
assert!(matches!(err, AppError::NotFound(_)));
}
#[tokio::test]
async fn resolution_against_active_domain_passes() {
let store = fjall_store().await;
create_domain(&store, &entry("active.example", DomainStatus::Active))
.await
.unwrap();
assert!(
assert_domain_active_for_resolution(&store, "active.example")
.await
.is_ok()
);
}
#[tokio::test]
async fn resolution_allowed_empty_keyspace_is_permissive() {
let store = fjall_store().await;
assert!(
assert_resolution_allowed(&store, "example.com", "did:webvh:Q1:example.com:user1")
.await
.is_ok()
);
}
#[tokio::test]
async fn resolution_allowed_happy_path() {
let store = fjall_store().await;
create_domain(&store, &entry("example.com", DomainStatus::Active))
.await
.unwrap();
assert!(
assert_resolution_allowed(&store, "example.com", "did:webvh:Q1:example.com:user1")
.await
.is_ok()
);
}
#[tokio::test]
async fn resolution_allowed_cross_domain_leakage_rejected() {
let store = fjall_store().await;
create_domain(&store, &entry("domain-a.example", DomainStatus::Active))
.await
.unwrap();
create_domain(&store, &entry("domain-b.example", DomainStatus::Active))
.await
.unwrap();
let err = assert_resolution_allowed(
&store,
"domain-b.example",
"did:webvh:Q1:domain-a.example:user1",
)
.await
.expect_err("cross-domain resolve must reject");
assert!(matches!(err, AppError::NotFound(_)));
}
#[tokio::test]
async fn resolution_allowed_disabled_domain_returns_503() {
let store = fjall_store().await;
create_domain(&store, &entry("disabled.example", DomainStatus::Disabled))
.await
.unwrap();
let err = assert_resolution_allowed(
&store,
"disabled.example",
"did:webvh:Q1:disabled.example:user1",
)
.await
.expect_err("disabled domain must 503");
assert!(matches!(err, AppError::DomainDisabled { .. }));
}
#[tokio::test]
async fn end_to_end_happy_path() {
let store = fjall_store().await;
create_domain(&store, &entry("example.com", DomainStatus::Active))
.await
.unwrap();
set_default_domain(&store, "example.com").await.unwrap();
let e = acl(
Role::Owner,
DomainScope::AllowedWithDefault {
domains: vec!["example.com".into()],
default: "example.com".into(),
},
);
let did = "did:webvh:QmABC:example.com:user1";
assert!(assert_did_host_allowed(&store, &e, did).await.is_ok());
}
#[tokio::test]
async fn end_to_end_did_host_not_configured() {
let store = fjall_store().await;
create_domain(&store, &entry("example.com", DomainStatus::Active))
.await
.unwrap();
let e = acl(Role::Owner, DomainScope::All);
let did = "did:webvh:QmABC:other.example:user1";
let err = assert_did_host_allowed(&store, &e, did)
.await
.expect_err("must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[tokio::test]
async fn end_to_end_acl_rejects() {
let store = fjall_store().await;
create_domain(&store, &entry("example.com", DomainStatus::Active))
.await
.unwrap();
let e = acl(
Role::Owner,
DomainScope::Allowed {
domains: vec!["allowed.example".into()],
},
);
create_domain(&store, &entry("allowed.example", DomainStatus::Active))
.await
.unwrap();
let did = "did:webvh:QmABC:example.com:user1";
let err = assert_did_host_allowed(&store, &e, did)
.await
.expect_err("must reject");
assert!(matches!(err, AppError::Forbidden(_)));
}
#[tokio::test]
async fn end_to_end_disabled_domain_rejects_with_400() {
let store = fjall_store().await;
create_domain(&store, &entry("example.com", DomainStatus::Disabled))
.await
.unwrap();
let e = acl(Role::Admin, DomainScope::All);
let did = "did:webvh:QmABC:example.com:user1";
let err = assert_did_host_allowed(&store, &e, did)
.await
.expect_err("disabled rejects");
assert!(matches!(err, AppError::Validation(_)));
}
}