1use bytes::Bytes;
29use serde::{Deserialize, Serialize};
30
31use crate::error::PodError;
32use crate::ldp::is_container;
33use crate::storage::Storage;
34use crate::wac::{serialize_turtle_acl, AclAuthorization, AclDocument, IdOrIds, IdRef};
35use crate::webid::generate_webid_html;
36
37#[derive(Debug, Clone, Deserialize, Serialize)]
39pub struct ProvisionPlan {
40 pub pubkey: String,
42 #[serde(default)]
44 pub display_name: Option<String>,
45 pub pod_base: String,
47 #[serde(default)]
49 pub containers: Vec<String>,
50 #[serde(default)]
52 pub root_acl: Option<AclDocument>,
53 #[serde(default)]
56 pub quota_bytes: Option<u64>,
57 #[cfg(feature = "provision-keys")]
72 #[serde(default)]
73 pub provision_keys: bool,
74}
75
76impl ProvisionPlan {
77 pub fn new(pubkey: impl Into<String>, pod_base: impl Into<String>) -> Self {
83 Self {
84 pubkey: pubkey.into(),
85 display_name: None,
86 pod_base: pod_base.into(),
87 containers: Vec::new(),
88 root_acl: None,
89 quota_bytes: None,
90 #[cfg(feature = "provision-keys")]
91 provision_keys: false,
92 }
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct ProvisionOutcome {
99 pub webid: String,
100 pub pod_root: String,
101 pub containers_created: Vec<String>,
102 pub quota_bytes: Option<u64>,
103 pub public_type_index: String,
106 pub private_type_index: String,
109 pub public_type_index_acl: String,
112}
113
114pub const PUBLIC_TYPE_INDEX_PATH: &str = "/settings/publicTypeIndex.jsonld";
120
121pub const PRIVATE_TYPE_INDEX_PATH: &str = "/settings/privateTypeIndex.jsonld";
123
124pub const PUBLIC_TYPE_INDEX_ACL_PATH: &str = "/settings/publicTypeIndex.jsonld.acl";
126
127fn render_type_index_body(visibility_marker: &str) -> String {
136 let body = serde_json::json!({
137 "@context": { "solid": "http://www.w3.org/ns/solid/terms#" },
138 "@id": "",
139 "@type": ["solid:TypeIndex", visibility_marker],
140 });
141 serde_json::to_string_pretty(&body).expect("static type-index JSON always serialises")
143}
144
145fn build_public_type_index_acl(webid: &str, resource_path: &str) -> AclDocument {
152 let owner = AclAuthorization {
153 id: Some("#owner".into()),
154 r#type: Some("acl:Authorization".into()),
155 agent: Some(IdOrIds::Single(IdRef { id: webid.into() })),
156 agent_class: None,
157 agent_group: None,
158 origin: None,
159 access_to: Some(IdOrIds::Single(IdRef {
160 id: resource_path.into(),
161 })),
162 default: None,
163 mode: Some(IdOrIds::Multiple(vec![
164 IdRef {
165 id: "acl:Read".into(),
166 },
167 IdRef {
168 id: "acl:Write".into(),
169 },
170 IdRef {
171 id: "acl:Control".into(),
172 },
173 ])),
174 condition: None,
175 };
176 let public = AclAuthorization {
177 id: Some("#public".into()),
178 r#type: Some("acl:Authorization".into()),
179 agent: None,
180 agent_class: Some(IdOrIds::Single(IdRef {
181 id: "foaf:Agent".into(),
182 })),
183 agent_group: None,
184 origin: None,
185 access_to: Some(IdOrIds::Single(IdRef {
186 id: resource_path.into(),
187 })),
188 default: None,
189 mode: Some(IdOrIds::Single(IdRef {
190 id: "acl:Read".into(),
191 })),
192 condition: None,
193 };
194 AclDocument {
195 context: None,
196 graph: Some(vec![owner, public]),
197 inherited: false,
198 }
199}
200
201pub async fn provision_pod<S: Storage + ?Sized>(
208 storage: &S,
209 plan: &ProvisionPlan,
210) -> Result<ProvisionOutcome, PodError> {
211 let pod_root = format!(
212 "{}/pods/{}/",
213 plan.pod_base.trim_end_matches('/'),
214 plan.pubkey
215 );
216 let webid = format!("{pod_root}profile/card#me");
217
218 let mut all_containers: Vec<String> = plan.containers.to_vec();
220 all_containers.push("/".into());
221 all_containers.push("/profile/".into());
222 all_containers.push("/settings/".into());
223 all_containers.sort();
225 all_containers.dedup();
226
227 let mut created = Vec::new();
228 for c in &all_containers {
229 if !is_container(c) {
230 return Err(PodError::InvalidPath(format!("not a container: {c}")));
231 }
232 let meta_key = format!("{}.meta", c.trim_end_matches('/'));
235 match storage
236 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
237 .await
238 {
239 Ok(_) => created.push(c.clone()),
240 Err(PodError::AlreadyExists(_)) => {}
241 Err(e) => return Err(e),
242 }
243 }
244
245 let webid_html =
247 generate_webid_html(&plan.pubkey, plan.display_name.as_deref(), &plan.pod_base);
248 storage
249 .put(
250 "/profile/card",
251 Bytes::from(webid_html.into_bytes()),
252 "text/html",
253 )
254 .await?;
255
256 if let Some(acl) = &plan.root_acl {
258 let body = serde_json::to_vec(acl)?;
259 storage
260 .put("/.acl", Bytes::from(body), "application/ld+json")
261 .await?;
262 }
263
264 let public_body = render_type_index_body("solid:ListedDocument");
273 storage
274 .put(
275 PUBLIC_TYPE_INDEX_PATH,
276 Bytes::from(public_body.into_bytes()),
277 "application/ld+json",
278 )
279 .await?;
280
281 let private_body = render_type_index_body("solid:UnlistedDocument");
282 storage
283 .put(
284 PRIVATE_TYPE_INDEX_PATH,
285 Bytes::from(private_body.into_bytes()),
286 "application/ld+json",
287 )
288 .await?;
289
290 let public_acl_resource_iri = format!(
294 "{}{}",
295 pod_root.trim_end_matches('/'),
296 PUBLIC_TYPE_INDEX_PATH,
297 );
298 let public_acl_doc = build_public_type_index_acl(&webid, &public_acl_resource_iri);
299 let public_acl_ttl = serialize_turtle_acl(&public_acl_doc);
300 storage
301 .put(
302 PUBLIC_TYPE_INDEX_ACL_PATH,
303 Bytes::from(public_acl_ttl.into_bytes()),
304 "text/turtle",
305 )
306 .await?;
307
308 Ok(ProvisionOutcome {
309 webid,
310 pod_root,
311 containers_created: created,
312 quota_bytes: plan.quota_bytes,
313 public_type_index: PUBLIC_TYPE_INDEX_PATH.to_string(),
314 private_type_index: PRIVATE_TYPE_INDEX_PATH.to_string(),
315 public_type_index_acl: PUBLIC_TYPE_INDEX_ACL_PATH.to_string(),
316 })
317}
318
319#[cfg(feature = "git-auto-init")]
335#[async_trait::async_trait]
336pub trait GitInitHook: Send + Sync {
337 async fn try_init_repo(&self, fs_pod_root: &std::path::Path) -> Result<(), PodError>;
341}
342
343#[cfg(feature = "git-auto-init")]
355pub async fn provision_pod_ext<S, H>(
356 storage: &S,
357 plan: &ProvisionPlan,
358 git_hook: Option<(&H, &std::path::Path)>,
359) -> Result<ProvisionOutcome, PodError>
360where
361 S: Storage + ?Sized,
362 H: GitInitHook,
363{
364 let outcome = provision_pod(storage, plan).await?;
365
366 if let Some((hook, fs_pod_root)) = git_hook {
367 if let Err(e) = hook.try_init_repo(fs_pod_root).await {
368 tracing::warn!(
369 target: "solid_pod_rs::provision",
370 pubkey = %plan.pubkey,
371 path = %fs_pod_root.display(),
372 error = %e,
373 "git auto-init failed (pod created, git skipped)",
374 );
375 }
376 }
377
378 Ok(outcome)
379}
380
381#[derive(Debug, Clone)]
387pub struct QuotaTracker {
388 quota_bytes: Option<u64>,
389 used_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
390}
391
392impl QuotaTracker {
393 pub fn new(quota_bytes: Option<u64>) -> Self {
394 Self {
395 quota_bytes,
396 used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
397 }
398 }
399
400 pub fn with_initial_used(quota_bytes: Option<u64>, used: u64) -> Self {
401 Self {
402 quota_bytes,
403 used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(used)),
404 }
405 }
406
407 pub fn used(&self) -> u64 {
409 self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
410 }
411
412 pub fn quota(&self) -> Option<u64> {
414 self.quota_bytes
415 }
416
417 pub fn reserve(&self, size: u64) -> Result<(), PodError> {
421 if let Some(q) = self.quota_bytes {
422 let cur = self.used();
423 if cur.saturating_add(size) > q {
424 return Err(PodError::PreconditionFailed(format!(
425 "quota exceeded: {cur}+{size} > {q}"
426 )));
427 }
428 }
429 self.used_bytes
430 .fetch_add(size, std::sync::atomic::Ordering::Relaxed);
431 Ok(())
432 }
433
434 pub fn release(&self, size: u64) {
436 self.used_bytes
437 .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
438 }
439}
440
441#[derive(Debug, Clone, Copy)]
449pub struct AdminOverride;
450
451pub fn check_admin_override(
455 header: Option<&str>,
456 configured: Option<&str>,
457) -> Option<AdminOverride> {
458 let header = header?;
459 let configured = configured?;
460 if header.len() != configured.len() {
461 return None;
462 }
463 let mut acc = 0u8;
464 for (a, b) in header.bytes().zip(configured.bytes()) {
465 acc |= a ^ b;
466 }
467 if acc == 0 {
468 Some(AdminOverride)
469 } else {
470 None
471 }
472}
473
474#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn quota_tracker_respects_limit() {
484 let q = QuotaTracker::new(Some(100));
485 q.reserve(40).unwrap();
486 q.reserve(40).unwrap();
487 let err = q.reserve(40).unwrap_err();
488 assert!(matches!(err, PodError::PreconditionFailed(_)));
489 assert_eq!(q.used(), 80);
490 }
491
492 #[test]
493 fn quota_tracker_release_frees_space() {
494 let q = QuotaTracker::new(Some(100));
495 q.reserve(60).unwrap();
496 q.release(30);
497 q.reserve(60).unwrap();
498 assert_eq!(q.used(), 90);
499 }
500
501 #[test]
502 fn quota_tracker_none_means_unlimited() {
503 let q = QuotaTracker::new(None);
504 q.reserve(u64::MAX / 2).unwrap();
505 q.reserve(u64::MAX / 2).unwrap();
506 }
507
508 #[test]
509 fn admin_override_matches_only_exact() {
510 let ok = check_admin_override(Some("topsecret"), Some("topsecret"));
511 assert!(ok.is_some());
512 assert!(check_admin_override(Some("topsecret "), Some("topsecret")).is_none());
513 assert!(check_admin_override(None, Some("topsecret")).is_none());
514 assert!(check_admin_override(Some("a"), None).is_none());
515 }
516
517 #[cfg(feature = "memory-backend")]
521 mod type_index_bootstrap {
522 use super::*;
523 use crate::storage::memory::MemoryBackend;
524 use crate::wac::{evaluate_access, parse_turtle_acl, AccessMode};
525 use serde_json::Value;
526
527 async fn provision_default_pod() -> (MemoryBackend, ProvisionOutcome) {
528 let pod = MemoryBackend::new();
529 let plan = ProvisionPlan {
530 pubkey: "0123".into(),
531 display_name: Some("Alice".into()),
532 pod_base: "https://pod.example".into(),
533 containers: vec!["/media/".into()],
534 root_acl: None,
535 quota_bytes: Some(10_000),
536 #[cfg(feature = "provision-keys")]
537 provision_keys: false,
538 };
539 let outcome = provision_pod(&pod, &plan).await.unwrap();
540 (pod, outcome)
541 }
542
543 #[tokio::test]
544 async fn provision_writes_public_type_index_with_listed_document() {
545 let (pod, outcome) = provision_default_pod().await;
546 assert_eq!(
547 outcome.public_type_index, PUBLIC_TYPE_INDEX_PATH,
548 "outcome must surface the public type-index path",
549 );
550
551 let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_PATH).await.unwrap();
552 assert_eq!(meta.content_type, "application/ld+json");
553
554 let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
555 assert_eq!(parsed["@id"], Value::String(String::new()));
556 assert_eq!(
557 parsed["@context"]["solid"],
558 "http://www.w3.org/ns/solid/terms#"
559 );
560 let types = parsed["@type"].as_array().expect("@type is array");
561 let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
562 assert!(type_strs.contains(&"solid:TypeIndex"), "{type_strs:?}");
563 assert!(
564 type_strs.contains(&"solid:ListedDocument"),
565 "public type index missing solid:ListedDocument visibility marker: {type_strs:?}",
566 );
567 assert!(
568 !type_strs.contains(&"solid:UnlistedDocument"),
569 "public type index must not carry solid:UnlistedDocument",
570 );
571 }
572
573 #[tokio::test]
574 async fn provision_writes_private_type_index_with_unlisted_document() {
575 let (pod, outcome) = provision_default_pod().await;
576 assert_eq!(outcome.private_type_index, PRIVATE_TYPE_INDEX_PATH);
577
578 let (body, meta) = pod.get(PRIVATE_TYPE_INDEX_PATH).await.unwrap();
579 assert_eq!(meta.content_type, "application/ld+json");
580
581 let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
582 assert_eq!(parsed["@id"], Value::String(String::new()));
583 let types = parsed["@type"].as_array().expect("@type is array");
584 let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
585 assert!(type_strs.contains(&"solid:TypeIndex"));
586 assert!(
587 type_strs.contains(&"solid:UnlistedDocument"),
588 "private type index missing solid:UnlistedDocument marker: {type_strs:?}",
589 );
590 assert!(
591 !type_strs.contains(&"solid:ListedDocument"),
592 "private type index must not carry solid:ListedDocument",
593 );
594 }
595
596 #[tokio::test]
597 async fn provision_writes_public_read_acl_on_public_type_index() {
598 let (pod, outcome) = provision_default_pod().await;
599 assert_eq!(outcome.public_type_index_acl, PUBLIC_TYPE_INDEX_ACL_PATH);
600
601 let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
602 assert_eq!(meta.content_type, "text/turtle");
603 let text = std::str::from_utf8(&body).expect("UTF-8 turtle");
604 assert!(text.contains("@prefix acl:"));
605 assert!(text.contains("acl:Authorization"));
606 assert!(text.contains("acl:Control"));
607 assert!(text.contains("foaf:Agent"));
608 }
609
610 #[tokio::test]
611 async fn public_type_index_acl_grants_foaf_agent_read() {
612 let (pod, outcome) = provision_default_pod().await;
613 let (body, _) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
614 let ttl = std::str::from_utf8(&body).unwrap();
615 let doc = parse_turtle_acl(ttl).expect("ACL parses");
616 let resource_iri = format!(
620 "{}{}",
621 outcome.pod_root.trim_end_matches('/'),
622 PUBLIC_TYPE_INDEX_PATH,
623 );
624
625 assert!(
626 evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Read, None,),
627 "public/anonymous read must be granted on publicTypeIndex.jsonld",
628 );
629 assert!(
630 !evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Write, None,),
631 "anonymous must not be granted write",
632 );
633 }
634
635 #[tokio::test]
636 async fn private_type_index_has_no_sibling_acl() {
637 let (pod, _) = provision_default_pod().await;
638 let missing = "/settings/privateTypeIndex.jsonld.acl";
639 assert!(
640 !pod.exists(missing).await.unwrap(),
641 "private type index must not have a sibling ACL; must inherit /settings/.acl",
642 );
643 }
644 }
645}