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