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