use bytes::Bytes;
use serde::{Deserialize, Serialize};
use crate::error::PodError;
use crate::ldp::is_container;
use crate::storage::Storage;
use crate::wac::{serialize_turtle_acl, AclAuthorization, AclDocument, IdOrIds, IdRef};
use crate::webid::generate_webid_html;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProvisionPlan {
pub pubkey: String,
#[serde(default)]
pub display_name: Option<String>,
pub pod_base: String,
#[serde(default)]
pub containers: Vec<String>,
#[serde(default)]
pub root_acl: Option<AclDocument>,
#[serde(default)]
pub quota_bytes: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ProvisionOutcome {
pub webid: String,
pub pod_root: String,
pub containers_created: Vec<String>,
pub quota_bytes: Option<u64>,
pub public_type_index: String,
pub private_type_index: String,
pub public_type_index_acl: String,
}
pub const PUBLIC_TYPE_INDEX_PATH: &str = "/settings/publicTypeIndex.jsonld";
pub const PRIVATE_TYPE_INDEX_PATH: &str = "/settings/privateTypeIndex.jsonld";
pub const PUBLIC_TYPE_INDEX_ACL_PATH: &str = "/settings/publicTypeIndex.jsonld.acl";
fn render_type_index_body(visibility_marker: &str) -> String {
let body = serde_json::json!({
"@context": { "solid": "http://www.w3.org/ns/solid/terms#" },
"@id": "",
"@type": ["solid:TypeIndex", visibility_marker],
});
serde_json::to_string_pretty(&body).expect("static type-index JSON always serialises")
}
fn build_public_type_index_acl(webid: &str, resource_path: &str) -> AclDocument {
let owner = AclAuthorization {
id: Some("#owner".into()),
r#type: Some("acl:Authorization".into()),
agent: Some(IdOrIds::Single(IdRef { id: webid.into() })),
agent_class: None,
agent_group: None,
origin: None,
access_to: Some(IdOrIds::Single(IdRef {
id: resource_path.into(),
})),
default: None,
mode: Some(IdOrIds::Multiple(vec![
IdRef { id: "acl:Read".into() },
IdRef {
id: "acl:Write".into(),
},
IdRef {
id: "acl:Control".into(),
},
])),
condition: None,
};
let public = AclAuthorization {
id: Some("#public".into()),
r#type: Some("acl:Authorization".into()),
agent: None,
agent_class: Some(IdOrIds::Single(IdRef {
id: "foaf:Agent".into(),
})),
agent_group: None,
origin: None,
access_to: Some(IdOrIds::Single(IdRef {
id: resource_path.into(),
})),
default: None,
mode: Some(IdOrIds::Single(IdRef { id: "acl:Read".into() })),
condition: None,
};
AclDocument {
context: None,
graph: Some(vec![owner, public]),
}
}
pub async fn provision_pod<S: Storage + ?Sized>(
storage: &S,
plan: &ProvisionPlan,
) -> Result<ProvisionOutcome, PodError> {
let pod_root = format!(
"{}/pods/{}/",
plan.pod_base.trim_end_matches('/'),
plan.pubkey
);
let webid = format!("{pod_root}profile/card#me");
let mut all_containers: Vec<String> = plan.containers.to_vec();
all_containers.push("/".into());
all_containers.push("/profile/".into());
all_containers.push("/settings/".into());
all_containers.sort();
all_containers.dedup();
let mut created = Vec::new();
for c in &all_containers {
if !is_container(c) {
return Err(PodError::InvalidPath(format!("not a container: {c}")));
}
let meta_key = format!("{}.meta", c.trim_end_matches('/'));
match storage
.put(
&meta_key,
Bytes::from_static(b"{}"),
"application/ld+json",
)
.await
{
Ok(_) => created.push(c.clone()),
Err(PodError::AlreadyExists(_)) => {}
Err(e) => return Err(e),
}
}
let webid_html = generate_webid_html(
&plan.pubkey,
plan.display_name.as_deref(),
&plan.pod_base,
);
storage
.put(
"/profile/card",
Bytes::from(webid_html.into_bytes()),
"text/html",
)
.await?;
if let Some(acl) = &plan.root_acl {
let body = serde_json::to_vec(acl)?;
storage
.put("/.acl", Bytes::from(body), "application/ld+json")
.await?;
}
let public_body = render_type_index_body("solid:ListedDocument");
storage
.put(
PUBLIC_TYPE_INDEX_PATH,
Bytes::from(public_body.into_bytes()),
"application/ld+json",
)
.await?;
let private_body = render_type_index_body("solid:UnlistedDocument");
storage
.put(
PRIVATE_TYPE_INDEX_PATH,
Bytes::from(private_body.into_bytes()),
"application/ld+json",
)
.await?;
let public_acl_resource_iri = format!(
"{}{}",
pod_root.trim_end_matches('/'),
PUBLIC_TYPE_INDEX_PATH,
);
let public_acl_doc = build_public_type_index_acl(&webid, &public_acl_resource_iri);
let public_acl_ttl = serialize_turtle_acl(&public_acl_doc);
storage
.put(
PUBLIC_TYPE_INDEX_ACL_PATH,
Bytes::from(public_acl_ttl.into_bytes()),
"text/turtle",
)
.await?;
Ok(ProvisionOutcome {
webid,
pod_root,
containers_created: created,
quota_bytes: plan.quota_bytes,
public_type_index: PUBLIC_TYPE_INDEX_PATH.to_string(),
private_type_index: PRIVATE_TYPE_INDEX_PATH.to_string(),
public_type_index_acl: PUBLIC_TYPE_INDEX_ACL_PATH.to_string(),
})
}
#[derive(Debug, Clone)]
pub struct QuotaTracker {
quota_bytes: Option<u64>,
used_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
impl QuotaTracker {
pub fn new(quota_bytes: Option<u64>) -> Self {
Self {
quota_bytes,
used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
pub fn with_initial_used(quota_bytes: Option<u64>, used: u64) -> Self {
Self {
quota_bytes,
used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(used)),
}
}
pub fn used(&self) -> u64 {
self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn quota(&self) -> Option<u64> {
self.quota_bytes
}
pub fn reserve(&self, size: u64) -> Result<(), PodError> {
if let Some(q) = self.quota_bytes {
let cur = self.used();
if cur.saturating_add(size) > q {
return Err(PodError::PreconditionFailed(format!(
"quota exceeded: {cur}+{size} > {q}"
)));
}
}
self.used_bytes
.fetch_add(size, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
pub fn release(&self, size: u64) {
self.used_bytes
.fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
}
}
#[derive(Debug, Clone, Copy)]
pub struct AdminOverride;
pub fn check_admin_override(
header: Option<&str>,
configured: Option<&str>,
) -> Option<AdminOverride> {
let header = header?;
let configured = configured?;
if header.len() != configured.len() {
return None;
}
let mut acc = 0u8;
for (a, b) in header.bytes().zip(configured.bytes()) {
acc |= a ^ b;
}
if acc == 0 {
Some(AdminOverride)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quota_tracker_respects_limit() {
let q = QuotaTracker::new(Some(100));
q.reserve(40).unwrap();
q.reserve(40).unwrap();
let err = q.reserve(40).unwrap_err();
assert!(matches!(err, PodError::PreconditionFailed(_)));
assert_eq!(q.used(), 80);
}
#[test]
fn quota_tracker_release_frees_space() {
let q = QuotaTracker::new(Some(100));
q.reserve(60).unwrap();
q.release(30);
q.reserve(60).unwrap();
assert_eq!(q.used(), 90);
}
#[test]
fn quota_tracker_none_means_unlimited() {
let q = QuotaTracker::new(None);
q.reserve(u64::MAX / 2).unwrap();
q.reserve(u64::MAX / 2).unwrap();
}
#[test]
fn admin_override_matches_only_exact() {
let ok = check_admin_override(Some("topsecret"), Some("topsecret"));
assert!(ok.is_some());
assert!(check_admin_override(Some("topsecret "), Some("topsecret")).is_none());
assert!(check_admin_override(None, Some("topsecret")).is_none());
assert!(check_admin_override(Some("a"), None).is_none());
}
#[cfg(feature = "memory-backend")]
mod type_index_bootstrap {
use super::*;
use crate::storage::memory::MemoryBackend;
use crate::wac::{evaluate_access, parse_turtle_acl, AccessMode};
use serde_json::Value;
async fn provision_default_pod() -> (MemoryBackend, ProvisionOutcome) {
let pod = MemoryBackend::new();
let plan = ProvisionPlan {
pubkey: "0123".into(),
display_name: Some("Alice".into()),
pod_base: "https://pod.example".into(),
containers: vec!["/media/".into()],
root_acl: None,
quota_bytes: Some(10_000),
};
let outcome = provision_pod(&pod, &plan).await.unwrap();
(pod, outcome)
}
#[tokio::test]
async fn provision_writes_public_type_index_with_listed_document() {
let (pod, outcome) = provision_default_pod().await;
assert_eq!(
outcome.public_type_index, PUBLIC_TYPE_INDEX_PATH,
"outcome must surface the public type-index path",
);
let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_PATH).await.unwrap();
assert_eq!(meta.content_type, "application/ld+json");
let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
assert_eq!(parsed["@id"], Value::String(String::new()));
assert_eq!(
parsed["@context"]["solid"],
"http://www.w3.org/ns/solid/terms#"
);
let types = parsed["@type"].as_array().expect("@type is array");
let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
assert!(type_strs.contains(&"solid:TypeIndex"), "{type_strs:?}");
assert!(
type_strs.contains(&"solid:ListedDocument"),
"public type index missing solid:ListedDocument visibility marker: {type_strs:?}",
);
assert!(
!type_strs.contains(&"solid:UnlistedDocument"),
"public type index must not carry solid:UnlistedDocument",
);
}
#[tokio::test]
async fn provision_writes_private_type_index_with_unlisted_document() {
let (pod, outcome) = provision_default_pod().await;
assert_eq!(outcome.private_type_index, PRIVATE_TYPE_INDEX_PATH);
let (body, meta) = pod.get(PRIVATE_TYPE_INDEX_PATH).await.unwrap();
assert_eq!(meta.content_type, "application/ld+json");
let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
assert_eq!(parsed["@id"], Value::String(String::new()));
let types = parsed["@type"].as_array().expect("@type is array");
let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
assert!(type_strs.contains(&"solid:TypeIndex"));
assert!(
type_strs.contains(&"solid:UnlistedDocument"),
"private type index missing solid:UnlistedDocument marker: {type_strs:?}",
);
assert!(
!type_strs.contains(&"solid:ListedDocument"),
"private type index must not carry solid:ListedDocument",
);
}
#[tokio::test]
async fn provision_writes_public_read_acl_on_public_type_index() {
let (pod, outcome) = provision_default_pod().await;
assert_eq!(outcome.public_type_index_acl, PUBLIC_TYPE_INDEX_ACL_PATH);
let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
assert_eq!(meta.content_type, "text/turtle");
let text = std::str::from_utf8(&body).expect("UTF-8 turtle");
assert!(text.contains("@prefix acl:"));
assert!(text.contains("acl:Authorization"));
assert!(text.contains("acl:Control"));
assert!(text.contains("foaf:Agent"));
}
#[tokio::test]
async fn public_type_index_acl_grants_foaf_agent_read() {
let (pod, outcome) = provision_default_pod().await;
let (body, _) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
let ttl = std::str::from_utf8(&body).unwrap();
let doc = parse_turtle_acl(ttl).expect("ACL parses");
let resource_iri = format!(
"{}{}",
outcome.pod_root.trim_end_matches('/'),
PUBLIC_TYPE_INDEX_PATH,
);
assert!(
evaluate_access(
Some(&doc),
None,
&resource_iri,
AccessMode::Read,
None,
),
"public/anonymous read must be granted on publicTypeIndex.jsonld",
);
assert!(
!evaluate_access(
Some(&doc),
None,
&resource_iri,
AccessMode::Write,
None,
),
"anonymous must not be granted write",
);
}
#[tokio::test]
async fn private_type_index_has_no_sibling_acl() {
let (pod, _) = provision_default_pod().await;
let missing = "/settings/privateTypeIndex.jsonld.acl";
assert!(
!pod.exists(missing).await.unwrap(),
"private type index must not have a sibling ACL; must inherit /settings/.acl",
);
}
}
}