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
73#[derive(Debug, Clone)]
75pub struct ProvisionOutcome {
76 pub webid: String,
77 pub pod_root: String,
78 pub containers_created: Vec<String>,
79 pub quota_bytes: Option<u64>,
80 pub public_type_index: String,
83 pub private_type_index: String,
86 pub public_type_index_acl: String,
89}
90
91pub const PUBLIC_TYPE_INDEX_PATH: &str = "/settings/publicTypeIndex.jsonld";
97
98pub const PRIVATE_TYPE_INDEX_PATH: &str = "/settings/privateTypeIndex.jsonld";
100
101pub const PUBLIC_TYPE_INDEX_ACL_PATH: &str = "/settings/publicTypeIndex.jsonld.acl";
103
104fn render_type_index_body(visibility_marker: &str) -> String {
113 let body = serde_json::json!({
114 "@context": { "solid": "http://www.w3.org/ns/solid/terms#" },
115 "@id": "",
116 "@type": ["solid:TypeIndex", visibility_marker],
117 });
118 serde_json::to_string_pretty(&body).expect("static type-index JSON always serialises")
120}
121
122fn build_public_type_index_acl(webid: &str, resource_path: &str) -> AclDocument {
129 let owner = AclAuthorization {
130 id: Some("#owner".into()),
131 r#type: Some("acl:Authorization".into()),
132 agent: Some(IdOrIds::Single(IdRef { id: webid.into() })),
133 agent_class: None,
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::Multiple(vec![
141 IdRef {
142 id: "acl:Read".into(),
143 },
144 IdRef {
145 id: "acl:Write".into(),
146 },
147 IdRef {
148 id: "acl:Control".into(),
149 },
150 ])),
151 condition: None,
152 };
153 let public = AclAuthorization {
154 id: Some("#public".into()),
155 r#type: Some("acl:Authorization".into()),
156 agent: None,
157 agent_class: Some(IdOrIds::Single(IdRef {
158 id: "foaf:Agent".into(),
159 })),
160 agent_group: None,
161 origin: None,
162 access_to: Some(IdOrIds::Single(IdRef {
163 id: resource_path.into(),
164 })),
165 default: None,
166 mode: Some(IdOrIds::Single(IdRef {
167 id: "acl:Read".into(),
168 })),
169 condition: None,
170 };
171 AclDocument {
172 context: None,
173 graph: Some(vec![owner, public]),
174 }
175}
176
177pub async fn provision_pod<S: Storage + ?Sized>(
184 storage: &S,
185 plan: &ProvisionPlan,
186) -> Result<ProvisionOutcome, PodError> {
187 let pod_root = format!(
188 "{}/pods/{}/",
189 plan.pod_base.trim_end_matches('/'),
190 plan.pubkey
191 );
192 let webid = format!("{pod_root}profile/card#me");
193
194 let mut all_containers: Vec<String> = plan.containers.to_vec();
196 all_containers.push("/".into());
197 all_containers.push("/profile/".into());
198 all_containers.push("/settings/".into());
199 all_containers.sort();
201 all_containers.dedup();
202
203 let mut created = Vec::new();
204 for c in &all_containers {
205 if !is_container(c) {
206 return Err(PodError::InvalidPath(format!("not a container: {c}")));
207 }
208 let meta_key = format!("{}.meta", c.trim_end_matches('/'));
211 match storage
212 .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
213 .await
214 {
215 Ok(_) => created.push(c.clone()),
216 Err(PodError::AlreadyExists(_)) => {}
217 Err(e) => return Err(e),
218 }
219 }
220
221 let webid_html =
223 generate_webid_html(&plan.pubkey, plan.display_name.as_deref(), &plan.pod_base);
224 storage
225 .put(
226 "/profile/card",
227 Bytes::from(webid_html.into_bytes()),
228 "text/html",
229 )
230 .await?;
231
232 if let Some(acl) = &plan.root_acl {
234 let body = serde_json::to_vec(acl)?;
235 storage
236 .put("/.acl", Bytes::from(body), "application/ld+json")
237 .await?;
238 }
239
240 let public_body = render_type_index_body("solid:ListedDocument");
249 storage
250 .put(
251 PUBLIC_TYPE_INDEX_PATH,
252 Bytes::from(public_body.into_bytes()),
253 "application/ld+json",
254 )
255 .await?;
256
257 let private_body = render_type_index_body("solid:UnlistedDocument");
258 storage
259 .put(
260 PRIVATE_TYPE_INDEX_PATH,
261 Bytes::from(private_body.into_bytes()),
262 "application/ld+json",
263 )
264 .await?;
265
266 let public_acl_resource_iri = format!(
270 "{}{}",
271 pod_root.trim_end_matches('/'),
272 PUBLIC_TYPE_INDEX_PATH,
273 );
274 let public_acl_doc = build_public_type_index_acl(&webid, &public_acl_resource_iri);
275 let public_acl_ttl = serialize_turtle_acl(&public_acl_doc);
276 storage
277 .put(
278 PUBLIC_TYPE_INDEX_ACL_PATH,
279 Bytes::from(public_acl_ttl.into_bytes()),
280 "text/turtle",
281 )
282 .await?;
283
284 Ok(ProvisionOutcome {
285 webid,
286 pod_root,
287 containers_created: created,
288 quota_bytes: plan.quota_bytes,
289 public_type_index: PUBLIC_TYPE_INDEX_PATH.to_string(),
290 private_type_index: PRIVATE_TYPE_INDEX_PATH.to_string(),
291 public_type_index_acl: PUBLIC_TYPE_INDEX_ACL_PATH.to_string(),
292 })
293}
294
295#[cfg(feature = "git-auto-init")]
311#[async_trait::async_trait]
312pub trait GitInitHook: Send + Sync {
313 async fn try_init_repo(&self, fs_pod_root: &std::path::Path) -> Result<(), PodError>;
317}
318
319#[cfg(feature = "git-auto-init")]
331pub async fn provision_pod_ext<S, H>(
332 storage: &S,
333 plan: &ProvisionPlan,
334 git_hook: Option<(&H, &std::path::Path)>,
335) -> Result<ProvisionOutcome, PodError>
336where
337 S: Storage + ?Sized,
338 H: GitInitHook,
339{
340 let outcome = provision_pod(storage, plan).await?;
341
342 if let Some((hook, fs_pod_root)) = git_hook {
343 if let Err(e) = hook.try_init_repo(fs_pod_root).await {
344 tracing::warn!(
345 target: "solid_pod_rs::provision",
346 pubkey = %plan.pubkey,
347 path = %fs_pod_root.display(),
348 error = %e,
349 "git auto-init failed (pod created, git skipped)",
350 );
351 }
352 }
353
354 Ok(outcome)
355}
356
357#[derive(Debug, Clone)]
363pub struct QuotaTracker {
364 quota_bytes: Option<u64>,
365 used_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
366}
367
368impl QuotaTracker {
369 pub fn new(quota_bytes: Option<u64>) -> Self {
370 Self {
371 quota_bytes,
372 used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
373 }
374 }
375
376 pub fn with_initial_used(quota_bytes: Option<u64>, used: u64) -> Self {
377 Self {
378 quota_bytes,
379 used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(used)),
380 }
381 }
382
383 pub fn used(&self) -> u64 {
385 self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
386 }
387
388 pub fn quota(&self) -> Option<u64> {
390 self.quota_bytes
391 }
392
393 pub fn reserve(&self, size: u64) -> Result<(), PodError> {
397 if let Some(q) = self.quota_bytes {
398 let cur = self.used();
399 if cur.saturating_add(size) > q {
400 return Err(PodError::PreconditionFailed(format!(
401 "quota exceeded: {cur}+{size} > {q}"
402 )));
403 }
404 }
405 self.used_bytes
406 .fetch_add(size, std::sync::atomic::Ordering::Relaxed);
407 Ok(())
408 }
409
410 pub fn release(&self, size: u64) {
412 self.used_bytes
413 .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
414 }
415}
416
417#[derive(Debug, Clone, Copy)]
425pub struct AdminOverride;
426
427pub fn check_admin_override(
431 header: Option<&str>,
432 configured: Option<&str>,
433) -> Option<AdminOverride> {
434 let header = header?;
435 let configured = configured?;
436 if header.len() != configured.len() {
437 return None;
438 }
439 let mut acc = 0u8;
440 for (a, b) in header.bytes().zip(configured.bytes()) {
441 acc |= a ^ b;
442 }
443 if acc == 0 {
444 Some(AdminOverride)
445 } else {
446 None
447 }
448}
449
450#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn quota_tracker_respects_limit() {
460 let q = QuotaTracker::new(Some(100));
461 q.reserve(40).unwrap();
462 q.reserve(40).unwrap();
463 let err = q.reserve(40).unwrap_err();
464 assert!(matches!(err, PodError::PreconditionFailed(_)));
465 assert_eq!(q.used(), 80);
466 }
467
468 #[test]
469 fn quota_tracker_release_frees_space() {
470 let q = QuotaTracker::new(Some(100));
471 q.reserve(60).unwrap();
472 q.release(30);
473 q.reserve(60).unwrap();
474 assert_eq!(q.used(), 90);
475 }
476
477 #[test]
478 fn quota_tracker_none_means_unlimited() {
479 let q = QuotaTracker::new(None);
480 q.reserve(u64::MAX / 2).unwrap();
481 q.reserve(u64::MAX / 2).unwrap();
482 }
483
484 #[test]
485 fn admin_override_matches_only_exact() {
486 let ok = check_admin_override(Some("topsecret"), Some("topsecret"));
487 assert!(ok.is_some());
488 assert!(check_admin_override(Some("topsecret "), Some("topsecret")).is_none());
489 assert!(check_admin_override(None, Some("topsecret")).is_none());
490 assert!(check_admin_override(Some("a"), None).is_none());
491 }
492
493 #[cfg(feature = "memory-backend")]
497 mod type_index_bootstrap {
498 use super::*;
499 use crate::storage::memory::MemoryBackend;
500 use crate::wac::{evaluate_access, parse_turtle_acl, AccessMode};
501 use serde_json::Value;
502
503 async fn provision_default_pod() -> (MemoryBackend, ProvisionOutcome) {
504 let pod = MemoryBackend::new();
505 let plan = ProvisionPlan {
506 pubkey: "0123".into(),
507 display_name: Some("Alice".into()),
508 pod_base: "https://pod.example".into(),
509 containers: vec!["/media/".into()],
510 root_acl: None,
511 quota_bytes: Some(10_000),
512 #[cfg(feature = "provision-keys")]
513 provision_keys: false,
514 };
515 let outcome = provision_pod(&pod, &plan).await.unwrap();
516 (pod, outcome)
517 }
518
519 #[tokio::test]
520 async fn provision_writes_public_type_index_with_listed_document() {
521 let (pod, outcome) = provision_default_pod().await;
522 assert_eq!(
523 outcome.public_type_index, PUBLIC_TYPE_INDEX_PATH,
524 "outcome must surface the public type-index path",
525 );
526
527 let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_PATH).await.unwrap();
528 assert_eq!(meta.content_type, "application/ld+json");
529
530 let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
531 assert_eq!(parsed["@id"], Value::String(String::new()));
532 assert_eq!(
533 parsed["@context"]["solid"],
534 "http://www.w3.org/ns/solid/terms#"
535 );
536 let types = parsed["@type"].as_array().expect("@type is array");
537 let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
538 assert!(type_strs.contains(&"solid:TypeIndex"), "{type_strs:?}");
539 assert!(
540 type_strs.contains(&"solid:ListedDocument"),
541 "public type index missing solid:ListedDocument visibility marker: {type_strs:?}",
542 );
543 assert!(
544 !type_strs.contains(&"solid:UnlistedDocument"),
545 "public type index must not carry solid:UnlistedDocument",
546 );
547 }
548
549 #[tokio::test]
550 async fn provision_writes_private_type_index_with_unlisted_document() {
551 let (pod, outcome) = provision_default_pod().await;
552 assert_eq!(outcome.private_type_index, PRIVATE_TYPE_INDEX_PATH);
553
554 let (body, meta) = pod.get(PRIVATE_TYPE_INDEX_PATH).await.unwrap();
555 assert_eq!(meta.content_type, "application/ld+json");
556
557 let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
558 assert_eq!(parsed["@id"], Value::String(String::new()));
559 let types = parsed["@type"].as_array().expect("@type is array");
560 let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
561 assert!(type_strs.contains(&"solid:TypeIndex"));
562 assert!(
563 type_strs.contains(&"solid:UnlistedDocument"),
564 "private type index missing solid:UnlistedDocument marker: {type_strs:?}",
565 );
566 assert!(
567 !type_strs.contains(&"solid:ListedDocument"),
568 "private type index must not carry solid:ListedDocument",
569 );
570 }
571
572 #[tokio::test]
573 async fn provision_writes_public_read_acl_on_public_type_index() {
574 let (pod, outcome) = provision_default_pod().await;
575 assert_eq!(outcome.public_type_index_acl, PUBLIC_TYPE_INDEX_ACL_PATH);
576
577 let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
578 assert_eq!(meta.content_type, "text/turtle");
579 let text = std::str::from_utf8(&body).expect("UTF-8 turtle");
580 assert!(text.contains("@prefix acl:"));
581 assert!(text.contains("acl:Authorization"));
582 assert!(text.contains("acl:Control"));
583 assert!(text.contains("foaf:Agent"));
584 }
585
586 #[tokio::test]
587 async fn public_type_index_acl_grants_foaf_agent_read() {
588 let (pod, outcome) = provision_default_pod().await;
589 let (body, _) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
590 let ttl = std::str::from_utf8(&body).unwrap();
591 let doc = parse_turtle_acl(ttl).expect("ACL parses");
592 let resource_iri = format!(
596 "{}{}",
597 outcome.pod_root.trim_end_matches('/'),
598 PUBLIC_TYPE_INDEX_PATH,
599 );
600
601 assert!(
602 evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Read, None,),
603 "public/anonymous read must be granted on publicTypeIndex.jsonld",
604 );
605 assert!(
606 !evaluate_access(Some(&doc), None, &resource_iri, AccessMode::Write, None,),
607 "anonymous must not be granted write",
608 );
609 }
610
611 #[tokio::test]
612 async fn private_type_index_has_no_sibling_acl() {
613 let (pod, _) = provision_default_pod().await;
614 let missing = "/settings/privateTypeIndex.jsonld.acl";
615 assert!(
616 !pod.exists(missing).await.unwrap(),
617 "private type index must not have a sibling ACL; must inherit /settings/.acl",
618 );
619 }
620 }
621}