1use bytes::Bytes;
19use serde::{Deserialize, Serialize};
20
21use crate::error::PodError;
22use crate::ldp::is_container;
23use crate::storage::Storage;
24use crate::wac::{serialize_turtle_acl, AclAuthorization, AclDocument, IdOrIds, IdRef};
25use crate::webid::generate_webid_html;
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct ProvisionPlan {
30 pub pubkey: String,
32 #[serde(default)]
34 pub display_name: Option<String>,
35 pub pod_base: String,
37 #[serde(default)]
39 pub containers: Vec<String>,
40 #[serde(default)]
42 pub root_acl: Option<AclDocument>,
43 #[serde(default)]
46 pub quota_bytes: Option<u64>,
47}
48
49#[derive(Debug, Clone)]
51pub struct ProvisionOutcome {
52 pub webid: String,
53 pub pod_root: String,
54 pub containers_created: Vec<String>,
55 pub quota_bytes: Option<u64>,
56 pub public_type_index: String,
59 pub private_type_index: String,
62 pub public_type_index_acl: String,
65}
66
67pub const PUBLIC_TYPE_INDEX_PATH: &str = "/settings/publicTypeIndex.jsonld";
73
74pub const PRIVATE_TYPE_INDEX_PATH: &str = "/settings/privateTypeIndex.jsonld";
76
77pub const PUBLIC_TYPE_INDEX_ACL_PATH: &str = "/settings/publicTypeIndex.jsonld.acl";
79
80fn render_type_index_body(visibility_marker: &str) -> String {
89 let body = serde_json::json!({
90 "@context": { "solid": "http://www.w3.org/ns/solid/terms#" },
91 "@id": "",
92 "@type": ["solid:TypeIndex", visibility_marker],
93 });
94 serde_json::to_string_pretty(&body).expect("static type-index JSON always serialises")
96}
97
98fn build_public_type_index_acl(webid: &str, resource_path: &str) -> AclDocument {
105 let owner = AclAuthorization {
106 id: Some("#owner".into()),
107 r#type: Some("acl:Authorization".into()),
108 agent: Some(IdOrIds::Single(IdRef { id: webid.into() })),
109 agent_class: None,
110 agent_group: None,
111 origin: None,
112 access_to: Some(IdOrIds::Single(IdRef {
113 id: resource_path.into(),
114 })),
115 default: None,
116 mode: Some(IdOrIds::Multiple(vec![
117 IdRef { id: "acl:Read".into() },
118 IdRef {
119 id: "acl:Write".into(),
120 },
121 IdRef {
122 id: "acl:Control".into(),
123 },
124 ])),
125 condition: None,
126 };
127 let public = AclAuthorization {
128 id: Some("#public".into()),
129 r#type: Some("acl:Authorization".into()),
130 agent: None,
131 agent_class: Some(IdOrIds::Single(IdRef {
132 id: "foaf:Agent".into(),
133 })),
134 agent_group: None,
135 origin: None,
136 access_to: Some(IdOrIds::Single(IdRef {
137 id: resource_path.into(),
138 })),
139 default: None,
140 mode: Some(IdOrIds::Single(IdRef { id: "acl:Read".into() })),
141 condition: None,
142 };
143 AclDocument {
144 context: None,
145 graph: Some(vec![owner, public]),
146 }
147}
148
149pub async fn provision_pod<S: Storage>(
156 storage: &S,
157 plan: &ProvisionPlan,
158) -> Result<ProvisionOutcome, PodError> {
159 let pod_root = format!(
160 "{}/pods/{}/",
161 plan.pod_base.trim_end_matches('/'),
162 plan.pubkey
163 );
164 let webid = format!("{pod_root}profile/card#me");
165
166 let mut all_containers: Vec<String> = plan.containers.to_vec();
168 all_containers.push("/".into());
169 all_containers.push("/profile/".into());
170 all_containers.push("/settings/".into());
171 all_containers.sort();
173 all_containers.dedup();
174
175 let mut created = Vec::new();
176 for c in &all_containers {
177 if !is_container(c) {
178 return Err(PodError::InvalidPath(format!("not a container: {c}")));
179 }
180 let meta_key = format!("{}.meta", c.trim_end_matches('/'));
183 match storage
184 .put(
185 &meta_key,
186 Bytes::from_static(b"{}"),
187 "application/ld+json",
188 )
189 .await
190 {
191 Ok(_) => created.push(c.clone()),
192 Err(PodError::AlreadyExists(_)) => {}
193 Err(e) => return Err(e),
194 }
195 }
196
197 let webid_html = generate_webid_html(
199 &plan.pubkey,
200 plan.display_name.as_deref(),
201 &plan.pod_base,
202 );
203 storage
204 .put(
205 "/profile/card",
206 Bytes::from(webid_html.into_bytes()),
207 "text/html",
208 )
209 .await?;
210
211 if let Some(acl) = &plan.root_acl {
213 let body = serde_json::to_vec(acl)?;
214 storage
215 .put("/.acl", Bytes::from(body), "application/ld+json")
216 .await?;
217 }
218
219 let public_body = render_type_index_body("solid:ListedDocument");
228 storage
229 .put(
230 PUBLIC_TYPE_INDEX_PATH,
231 Bytes::from(public_body.into_bytes()),
232 "application/ld+json",
233 )
234 .await?;
235
236 let private_body = render_type_index_body("solid:UnlistedDocument");
237 storage
238 .put(
239 PRIVATE_TYPE_INDEX_PATH,
240 Bytes::from(private_body.into_bytes()),
241 "application/ld+json",
242 )
243 .await?;
244
245 let public_acl_resource_iri = format!(
249 "{}{}",
250 pod_root.trim_end_matches('/'),
251 PUBLIC_TYPE_INDEX_PATH,
252 );
253 let public_acl_doc = build_public_type_index_acl(&webid, &public_acl_resource_iri);
254 let public_acl_ttl = serialize_turtle_acl(&public_acl_doc);
255 storage
256 .put(
257 PUBLIC_TYPE_INDEX_ACL_PATH,
258 Bytes::from(public_acl_ttl.into_bytes()),
259 "text/turtle",
260 )
261 .await?;
262
263 Ok(ProvisionOutcome {
264 webid,
265 pod_root,
266 containers_created: created,
267 quota_bytes: plan.quota_bytes,
268 public_type_index: PUBLIC_TYPE_INDEX_PATH.to_string(),
269 private_type_index: PRIVATE_TYPE_INDEX_PATH.to_string(),
270 public_type_index_acl: PUBLIC_TYPE_INDEX_ACL_PATH.to_string(),
271 })
272}
273
274#[derive(Debug, Clone)]
280pub struct QuotaTracker {
281 quota_bytes: Option<u64>,
282 used_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
283}
284
285impl QuotaTracker {
286 pub fn new(quota_bytes: Option<u64>) -> Self {
287 Self {
288 quota_bytes,
289 used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
290 }
291 }
292
293 pub fn with_initial_used(quota_bytes: Option<u64>, used: u64) -> Self {
294 Self {
295 quota_bytes,
296 used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(used)),
297 }
298 }
299
300 pub fn used(&self) -> u64 {
302 self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
303 }
304
305 pub fn quota(&self) -> Option<u64> {
307 self.quota_bytes
308 }
309
310 pub fn reserve(&self, size: u64) -> Result<(), PodError> {
314 if let Some(q) = self.quota_bytes {
315 let cur = self.used();
316 if cur.saturating_add(size) > q {
317 return Err(PodError::PreconditionFailed(format!(
318 "quota exceeded: {cur}+{size} > {q}"
319 )));
320 }
321 }
322 self.used_bytes
323 .fetch_add(size, std::sync::atomic::Ordering::Relaxed);
324 Ok(())
325 }
326
327 pub fn release(&self, size: u64) {
329 self.used_bytes
330 .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
331 }
332}
333
334#[derive(Debug, Clone, Copy)]
342pub struct AdminOverride;
343
344pub fn check_admin_override(
348 header: Option<&str>,
349 configured: Option<&str>,
350) -> Option<AdminOverride> {
351 let header = header?;
352 let configured = configured?;
353 if header.len() != configured.len() {
354 return None;
355 }
356 let mut acc = 0u8;
357 for (a, b) in header.bytes().zip(configured.bytes()) {
358 acc |= a ^ b;
359 }
360 if acc == 0 {
361 Some(AdminOverride)
362 } else {
363 None
364 }
365}
366
367#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn quota_tracker_respects_limit() {
377 let q = QuotaTracker::new(Some(100));
378 q.reserve(40).unwrap();
379 q.reserve(40).unwrap();
380 let err = q.reserve(40).unwrap_err();
381 assert!(matches!(err, PodError::PreconditionFailed(_)));
382 assert_eq!(q.used(), 80);
383 }
384
385 #[test]
386 fn quota_tracker_release_frees_space() {
387 let q = QuotaTracker::new(Some(100));
388 q.reserve(60).unwrap();
389 q.release(30);
390 q.reserve(60).unwrap();
391 assert_eq!(q.used(), 90);
392 }
393
394 #[test]
395 fn quota_tracker_none_means_unlimited() {
396 let q = QuotaTracker::new(None);
397 q.reserve(u64::MAX / 2).unwrap();
398 q.reserve(u64::MAX / 2).unwrap();
399 }
400
401 #[test]
402 fn admin_override_matches_only_exact() {
403 let ok = check_admin_override(Some("topsecret"), Some("topsecret"));
404 assert!(ok.is_some());
405 assert!(check_admin_override(Some("topsecret "), Some("topsecret")).is_none());
406 assert!(check_admin_override(None, Some("topsecret")).is_none());
407 assert!(check_admin_override(Some("a"), None).is_none());
408 }
409
410 #[cfg(feature = "memory-backend")]
414 mod type_index_bootstrap {
415 use super::*;
416 use crate::storage::memory::MemoryBackend;
417 use crate::wac::{evaluate_access, parse_turtle_acl, AccessMode};
418 use serde_json::Value;
419
420 async fn provision_default_pod() -> (MemoryBackend, ProvisionOutcome) {
421 let pod = MemoryBackend::new();
422 let plan = ProvisionPlan {
423 pubkey: "0123".into(),
424 display_name: Some("Alice".into()),
425 pod_base: "https://pod.example".into(),
426 containers: vec!["/media/".into()],
427 root_acl: None,
428 quota_bytes: Some(10_000),
429 };
430 let outcome = provision_pod(&pod, &plan).await.unwrap();
431 (pod, outcome)
432 }
433
434 #[tokio::test]
435 async fn provision_writes_public_type_index_with_listed_document() {
436 let (pod, outcome) = provision_default_pod().await;
437 assert_eq!(
438 outcome.public_type_index, PUBLIC_TYPE_INDEX_PATH,
439 "outcome must surface the public type-index path",
440 );
441
442 let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_PATH).await.unwrap();
443 assert_eq!(meta.content_type, "application/ld+json");
444
445 let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
446 assert_eq!(parsed["@id"], Value::String(String::new()));
447 assert_eq!(
448 parsed["@context"]["solid"],
449 "http://www.w3.org/ns/solid/terms#"
450 );
451 let types = parsed["@type"].as_array().expect("@type is array");
452 let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
453 assert!(type_strs.contains(&"solid:TypeIndex"), "{type_strs:?}");
454 assert!(
455 type_strs.contains(&"solid:ListedDocument"),
456 "public type index missing solid:ListedDocument visibility marker: {type_strs:?}",
457 );
458 assert!(
459 !type_strs.contains(&"solid:UnlistedDocument"),
460 "public type index must not carry solid:UnlistedDocument",
461 );
462 }
463
464 #[tokio::test]
465 async fn provision_writes_private_type_index_with_unlisted_document() {
466 let (pod, outcome) = provision_default_pod().await;
467 assert_eq!(outcome.private_type_index, PRIVATE_TYPE_INDEX_PATH);
468
469 let (body, meta) = pod.get(PRIVATE_TYPE_INDEX_PATH).await.unwrap();
470 assert_eq!(meta.content_type, "application/ld+json");
471
472 let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
473 assert_eq!(parsed["@id"], Value::String(String::new()));
474 let types = parsed["@type"].as_array().expect("@type is array");
475 let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
476 assert!(type_strs.contains(&"solid:TypeIndex"));
477 assert!(
478 type_strs.contains(&"solid:UnlistedDocument"),
479 "private type index missing solid:UnlistedDocument marker: {type_strs:?}",
480 );
481 assert!(
482 !type_strs.contains(&"solid:ListedDocument"),
483 "private type index must not carry solid:ListedDocument",
484 );
485 }
486
487 #[tokio::test]
488 async fn provision_writes_public_read_acl_on_public_type_index() {
489 let (pod, outcome) = provision_default_pod().await;
490 assert_eq!(outcome.public_type_index_acl, PUBLIC_TYPE_INDEX_ACL_PATH);
491
492 let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
493 assert_eq!(meta.content_type, "text/turtle");
494 let text = std::str::from_utf8(&body).expect("UTF-8 turtle");
495 assert!(text.contains("@prefix acl:"));
496 assert!(text.contains("acl:Authorization"));
497 assert!(text.contains("acl:Control"));
498 assert!(text.contains("foaf:Agent"));
499 }
500
501 #[tokio::test]
502 async fn public_type_index_acl_grants_foaf_agent_read() {
503 let (pod, outcome) = provision_default_pod().await;
504 let (body, _) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
505 let ttl = std::str::from_utf8(&body).unwrap();
506 let doc = parse_turtle_acl(ttl).expect("ACL parses");
507 let resource_iri = format!(
511 "{}{}",
512 outcome.pod_root.trim_end_matches('/'),
513 PUBLIC_TYPE_INDEX_PATH,
514 );
515
516 assert!(
517 evaluate_access(
518 Some(&doc),
519 None,
520 &resource_iri,
521 AccessMode::Read,
522 None,
523 ),
524 "public/anonymous read must be granted on publicTypeIndex.jsonld",
525 );
526 assert!(
527 !evaluate_access(
528 Some(&doc),
529 None,
530 &resource_iri,
531 AccessMode::Write,
532 None,
533 ),
534 "anonymous must not be granted write",
535 );
536 }
537
538 #[tokio::test]
539 async fn private_type_index_has_no_sibling_acl() {
540 let (pod, _) = provision_default_pod().await;
541 let missing = "/settings/privateTypeIndex.jsonld.acl";
542 assert!(
543 !pod.exists(missing).await.unwrap(),
544 "private type index must not have a sibling ACL; must inherit /settings/.acl",
545 );
546 }
547 }
548}