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