Skip to main content

solid_pod_rs/
provision.rs

1//! Pod provisioning — seeded containers, WebID + account scaffolding,
2//! admin override, quota enforcement.
3//!
4//! The provisioning surface is intentionally declarative: callers
5//! describe what the pod should look like (containers, ACLs, a WebID
6//! profile document) and the module wires them into a `Storage`
7//! backend. Admin-mode callers bypass ownership checks.
8//!
9//! Parity note (rows 14/164/166, JSS #301 + #297): provisioning also
10//! drops `settings/publicTypeIndex.jsonld` (typed
11//! `solid:TypeIndex + solid:ListedDocument`),
12//! `settings/privateTypeIndex.jsonld` (typed
13//! `solid:TypeIndex + solid:UnlistedDocument`) and a public-read ACL
14//! carve-out `settings/publicTypeIndex.jsonld.acl` so Solid clients
15//! can discover a freshly bootstrapped pod's public profile without
16//! fighting the default-private `/settings/.acl`.
17
18use 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/// Seed plan applied to a fresh pod.
28#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct ProvisionPlan {
30    /// Pubkey (hex) that owns the pod.
31    pub pubkey: String,
32    /// Optional display name for the WebID profile.
33    #[serde(default)]
34    pub display_name: Option<String>,
35    /// Public pod base URL (used to render the WebID).
36    pub pod_base: String,
37    /// Containers to create (paths must end with `/`).
38    #[serde(default)]
39    pub containers: Vec<String>,
40    /// ACL document to drop at the pod root.
41    #[serde(default)]
42    pub root_acl: Option<AclDocument>,
43    /// Bytes quota. `None` means unlimited (but a real consumer crate
44    /// is strongly encouraged to set one).
45    #[serde(default)]
46    pub quota_bytes: Option<u64>,
47}
48
49/// Result of provisioning a pod.
50#[derive(Debug, Clone)]
51pub struct ProvisionOutcome {
52    pub webid: String,
53    pub pod_root: String,
54    pub containers_created: Vec<String>,
55    pub quota_bytes: Option<u64>,
56    /// Storage path of the public type-index resource
57    /// (`/settings/publicTypeIndex.jsonld`).
58    pub public_type_index: String,
59    /// Storage path of the private type-index resource
60    /// (`/settings/privateTypeIndex.jsonld`).
61    pub private_type_index: String,
62    /// Storage path of the ACL carve-out that grants public read on
63    /// the public type index (`/settings/publicTypeIndex.jsonld.acl`).
64    pub public_type_index_acl: String,
65}
66
67// ---------------------------------------------------------------------------
68// Type-index bootstrap helpers
69// ---------------------------------------------------------------------------
70
71/// Storage path of the public type-index document.
72pub const PUBLIC_TYPE_INDEX_PATH: &str = "/settings/publicTypeIndex.jsonld";
73
74/// Storage path of the private type-index document.
75pub const PRIVATE_TYPE_INDEX_PATH: &str = "/settings/privateTypeIndex.jsonld";
76
77/// Storage path of the sibling ACL for the public type-index document.
78pub const PUBLIC_TYPE_INDEX_ACL_PATH: &str = "/settings/publicTypeIndex.jsonld.acl";
79
80/// Render the JSON-LD body for a type-index document.
81///
82/// JSS writes the body literally (commit 54e4433, #301) with:
83/// - `@context` binding the `solid` namespace,
84/// - `@id` as the empty string (relative self-reference),
85/// - `@type` listing `solid:TypeIndex` plus either
86///   `solid:ListedDocument` (public) or `solid:UnlistedDocument`
87///   (private).
88fn render_type_index_body(visibility_marker: &str) -> String {
89    let body = serde_json::json!({
90        "@context": { "solid": "http://www.w3.org/ns/solid/terms#" },
91        "@id": "",
92        "@type": ["solid:TypeIndex", visibility_marker],
93    });
94    // Pretty-printed for human-readability on disk; clients parse either way.
95    serde_json::to_string_pretty(&body).expect("static type-index JSON always serialises")
96}
97
98/// Build the ACL document for `publicTypeIndex.jsonld` that grants:
99/// - the pod owner (`WebID`) `acl:Read`, `acl:Write`, `acl:Control`,
100/// - the public (`foaf:Agent`) `acl:Read` only.
101///
102/// The ACL sits on the resource itself (not the parent container), so
103/// it overrides the default-private `/settings/.acl`.
104fn build_public_type_index_acl(webid: &str, resource_path: &str) -> AclDocument {
105    let owner = AclAuthorization {
106        id: Some("#owner".into()),
107        r#type: Some("acl:Authorization".into()),
108        agent: Some(IdOrIds::Single(IdRef { id: webid.into() })),
109        agent_class: None,
110        agent_group: None,
111        origin: None,
112        access_to: Some(IdOrIds::Single(IdRef {
113            id: resource_path.into(),
114        })),
115        default: None,
116        mode: Some(IdOrIds::Multiple(vec![
117            IdRef { id: "acl:Read".into() },
118            IdRef {
119                id: "acl:Write".into(),
120            },
121            IdRef {
122                id: "acl:Control".into(),
123            },
124        ])),
125        condition: None,
126    };
127    let public = AclAuthorization {
128        id: Some("#public".into()),
129        r#type: Some("acl:Authorization".into()),
130        agent: None,
131        agent_class: Some(IdOrIds::Single(IdRef {
132            id: "foaf:Agent".into(),
133        })),
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::Single(IdRef { id: "acl:Read".into() })),
141        condition: None,
142    };
143    AclDocument {
144        context: None,
145        graph: Some(vec![owner, public]),
146    }
147}
148
149/// Seed a pod on the provided storage.
150///
151/// * Creates every container in `plan.containers` (idempotent — the
152///   function treats `AlreadyExists` as success).
153/// * Writes a WebID profile HTML at `<pod_base>/pods/<pubkey>/profile/card`.
154/// * Writes a root ACL document if `plan.root_acl` is supplied.
155pub async fn provision_pod<S: Storage>(
156    storage: &S,
157    plan: &ProvisionPlan,
158) -> Result<ProvisionOutcome, PodError> {
159    let pod_root = format!(
160        "{}/pods/{}/",
161        plan.pod_base.trim_end_matches('/'),
162        plan.pubkey
163    );
164    let webid = format!("{pod_root}profile/card#me");
165
166    // Ensure the pod root + default containers exist.
167    let mut all_containers: Vec<String> = plan.containers.to_vec();
168    all_containers.push("/".into());
169    all_containers.push("/profile/".into());
170    all_containers.push("/settings/".into());
171    // Deduplicate.
172    all_containers.sort();
173    all_containers.dedup();
174
175    let mut created = Vec::new();
176    for c in &all_containers {
177        if !is_container(c) {
178            return Err(PodError::InvalidPath(format!("not a container: {c}")));
179        }
180        // Create the `.meta` sidecar — this is the idiomatic way to
181        // materialise a bare container without a body.
182        let meta_key = format!("{}.meta", c.trim_end_matches('/'));
183        match storage
184            .put(
185                &meta_key,
186                Bytes::from_static(b"{}"),
187                "application/ld+json",
188            )
189            .await
190        {
191            Ok(_) => created.push(c.clone()),
192            Err(PodError::AlreadyExists(_)) => {}
193            Err(e) => return Err(e),
194        }
195    }
196
197    // Write WebID profile.
198    let webid_html = generate_webid_html(
199        &plan.pubkey,
200        plan.display_name.as_deref(),
201        &plan.pod_base,
202    );
203    storage
204        .put(
205            "/profile/card",
206            Bytes::from(webid_html.into_bytes()),
207            "text/html",
208        )
209        .await?;
210
211    // Write root ACL if supplied.
212    if let Some(acl) = &plan.root_acl {
213        let body = serde_json::to_vec(acl)?;
214        storage
215            .put("/.acl", Bytes::from(body), "application/ld+json")
216            .await?;
217    }
218
219    // -------------------------------------------------------------------
220    // Type-index bootstrap (rows 14/164/166 — JSS #301 + #297).
221    // The two `*.jsonld` bodies differ only in the visibility marker.
222    // The public one gets a sibling ACL granting `foaf:Agent` read and
223    // the owner full control; the private one inherits the default
224    // (owner-only) ACL from `/settings/.acl`, so we deliberately do
225    // *not* emit a sibling for it.
226    // -------------------------------------------------------------------
227    let public_body = render_type_index_body("solid:ListedDocument");
228    storage
229        .put(
230            PUBLIC_TYPE_INDEX_PATH,
231            Bytes::from(public_body.into_bytes()),
232            "application/ld+json",
233        )
234        .await?;
235
236    let private_body = render_type_index_body("solid:UnlistedDocument");
237    storage
238        .put(
239            PRIVATE_TYPE_INDEX_PATH,
240            Bytes::from(private_body.into_bytes()),
241            "application/ld+json",
242        )
243        .await?;
244
245    // Use an absolute resource IRI so the Turtle serialiser wraps the
246    // target in `<>` (otherwise a `.` inside the path — e.g. in
247    // `.jsonld` — trips the statement splitter on round-trip).
248    let public_acl_resource_iri = format!(
249        "{}{}",
250        pod_root.trim_end_matches('/'),
251        PUBLIC_TYPE_INDEX_PATH,
252    );
253    let public_acl_doc = build_public_type_index_acl(&webid, &public_acl_resource_iri);
254    let public_acl_ttl = serialize_turtle_acl(&public_acl_doc);
255    storage
256        .put(
257            PUBLIC_TYPE_INDEX_ACL_PATH,
258            Bytes::from(public_acl_ttl.into_bytes()),
259            "text/turtle",
260        )
261        .await?;
262
263    Ok(ProvisionOutcome {
264        webid,
265        pod_root,
266        containers_created: created,
267        quota_bytes: plan.quota_bytes,
268        public_type_index: PUBLIC_TYPE_INDEX_PATH.to_string(),
269        private_type_index: PRIVATE_TYPE_INDEX_PATH.to_string(),
270        public_type_index_acl: PUBLIC_TYPE_INDEX_ACL_PATH.to_string(),
271    })
272}
273
274// ---------------------------------------------------------------------------
275// Quota enforcement
276// ---------------------------------------------------------------------------
277
278/// Tracks per-pod byte usage against a configurable quota.
279#[derive(Debug, Clone)]
280pub struct QuotaTracker {
281    quota_bytes: Option<u64>,
282    used_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
283}
284
285impl QuotaTracker {
286    pub fn new(quota_bytes: Option<u64>) -> Self {
287        Self {
288            quota_bytes,
289            used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
290        }
291    }
292
293    pub fn with_initial_used(quota_bytes: Option<u64>, used: u64) -> Self {
294        Self {
295            quota_bytes,
296            used_bytes: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(used)),
297        }
298    }
299
300    /// Bytes currently accounted for.
301    pub fn used(&self) -> u64 {
302        self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
303    }
304
305    /// Configured quota, if any.
306    pub fn quota(&self) -> Option<u64> {
307        self.quota_bytes
308    }
309
310    /// Reserve `size` bytes. Returns `Err(PodError::PreconditionFailed)`
311    /// when the operation would exceed the quota, without mutating the
312    /// tracker.
313    pub fn reserve(&self, size: u64) -> Result<(), PodError> {
314        if let Some(q) = self.quota_bytes {
315            let cur = self.used();
316            if cur.saturating_add(size) > q {
317                return Err(PodError::PreconditionFailed(format!(
318                    "quota exceeded: {cur}+{size} > {q}"
319                )));
320            }
321        }
322        self.used_bytes
323            .fetch_add(size, std::sync::atomic::Ordering::Relaxed);
324        Ok(())
325    }
326
327    /// Release `size` bytes previously reserved (e.g. on DELETE).
328    pub fn release(&self, size: u64) {
329        self.used_bytes
330            .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Admin override
336// ---------------------------------------------------------------------------
337
338/// A verified admin-override marker. The consumer crate constructs this
339/// only after validating a shared-secret header against configuration;
340/// the marker carries no data beyond its own existence.
341#[derive(Debug, Clone, Copy)]
342pub struct AdminOverride;
343
344/// Match an admin-secret header value against the configured secret.
345/// Both sides are compared with constant-time equality to avoid
346/// timing leaks. Returns `Some(AdminOverride)` on match.
347pub fn check_admin_override(
348    header: Option<&str>,
349    configured: Option<&str>,
350) -> Option<AdminOverride> {
351    let header = header?;
352    let configured = configured?;
353    if header.len() != configured.len() {
354        return None;
355    }
356    let mut acc = 0u8;
357    for (a, b) in header.bytes().zip(configured.bytes()) {
358        acc |= a ^ b;
359    }
360    if acc == 0 {
361        Some(AdminOverride)
362    } else {
363        None
364    }
365}
366
367// ---------------------------------------------------------------------------
368// Tests
369// ---------------------------------------------------------------------------
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn quota_tracker_respects_limit() {
377        let q = QuotaTracker::new(Some(100));
378        q.reserve(40).unwrap();
379        q.reserve(40).unwrap();
380        let err = q.reserve(40).unwrap_err();
381        assert!(matches!(err, PodError::PreconditionFailed(_)));
382        assert_eq!(q.used(), 80);
383    }
384
385    #[test]
386    fn quota_tracker_release_frees_space() {
387        let q = QuotaTracker::new(Some(100));
388        q.reserve(60).unwrap();
389        q.release(30);
390        q.reserve(60).unwrap();
391        assert_eq!(q.used(), 90);
392    }
393
394    #[test]
395    fn quota_tracker_none_means_unlimited() {
396        let q = QuotaTracker::new(None);
397        q.reserve(u64::MAX / 2).unwrap();
398        q.reserve(u64::MAX / 2).unwrap();
399    }
400
401    #[test]
402    fn admin_override_matches_only_exact() {
403        let ok = check_admin_override(Some("topsecret"), Some("topsecret"));
404        assert!(ok.is_some());
405        assert!(check_admin_override(Some("topsecret "), Some("topsecret")).is_none());
406        assert!(check_admin_override(None, Some("topsecret")).is_none());
407        assert!(check_admin_override(Some("a"), None).is_none());
408    }
409
410    // -------------------------------------------------------------------
411    // Type-index bootstrap tests (rows 14/164/166).
412    // -------------------------------------------------------------------
413    #[cfg(feature = "memory-backend")]
414    mod type_index_bootstrap {
415        use super::*;
416        use crate::storage::memory::MemoryBackend;
417        use crate::wac::{evaluate_access, parse_turtle_acl, AccessMode};
418        use serde_json::Value;
419
420        async fn provision_default_pod() -> (MemoryBackend, ProvisionOutcome) {
421            let pod = MemoryBackend::new();
422            let plan = ProvisionPlan {
423                pubkey: "0123".into(),
424                display_name: Some("Alice".into()),
425                pod_base: "https://pod.example".into(),
426                containers: vec!["/media/".into()],
427                root_acl: None,
428                quota_bytes: Some(10_000),
429            };
430            let outcome = provision_pod(&pod, &plan).await.unwrap();
431            (pod, outcome)
432        }
433
434        #[tokio::test]
435        async fn provision_writes_public_type_index_with_listed_document() {
436            let (pod, outcome) = provision_default_pod().await;
437            assert_eq!(
438                outcome.public_type_index, PUBLIC_TYPE_INDEX_PATH,
439                "outcome must surface the public type-index path",
440            );
441
442            let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_PATH).await.unwrap();
443            assert_eq!(meta.content_type, "application/ld+json");
444
445            let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
446            assert_eq!(parsed["@id"], Value::String(String::new()));
447            assert_eq!(
448                parsed["@context"]["solid"],
449                "http://www.w3.org/ns/solid/terms#"
450            );
451            let types = parsed["@type"].as_array().expect("@type is array");
452            let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
453            assert!(type_strs.contains(&"solid:TypeIndex"), "{type_strs:?}");
454            assert!(
455                type_strs.contains(&"solid:ListedDocument"),
456                "public type index missing solid:ListedDocument visibility marker: {type_strs:?}",
457            );
458            assert!(
459                !type_strs.contains(&"solid:UnlistedDocument"),
460                "public type index must not carry solid:UnlistedDocument",
461            );
462        }
463
464        #[tokio::test]
465        async fn provision_writes_private_type_index_with_unlisted_document() {
466            let (pod, outcome) = provision_default_pod().await;
467            assert_eq!(outcome.private_type_index, PRIVATE_TYPE_INDEX_PATH);
468
469            let (body, meta) = pod.get(PRIVATE_TYPE_INDEX_PATH).await.unwrap();
470            assert_eq!(meta.content_type, "application/ld+json");
471
472            let parsed: Value = serde_json::from_slice(&body).expect("valid JSON-LD");
473            assert_eq!(parsed["@id"], Value::String(String::new()));
474            let types = parsed["@type"].as_array().expect("@type is array");
475            let type_strs: Vec<&str> = types.iter().filter_map(Value::as_str).collect();
476            assert!(type_strs.contains(&"solid:TypeIndex"));
477            assert!(
478                type_strs.contains(&"solid:UnlistedDocument"),
479                "private type index missing solid:UnlistedDocument marker: {type_strs:?}",
480            );
481            assert!(
482                !type_strs.contains(&"solid:ListedDocument"),
483                "private type index must not carry solid:ListedDocument",
484            );
485        }
486
487        #[tokio::test]
488        async fn provision_writes_public_read_acl_on_public_type_index() {
489            let (pod, outcome) = provision_default_pod().await;
490            assert_eq!(outcome.public_type_index_acl, PUBLIC_TYPE_INDEX_ACL_PATH);
491
492            let (body, meta) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
493            assert_eq!(meta.content_type, "text/turtle");
494            let text = std::str::from_utf8(&body).expect("UTF-8 turtle");
495            assert!(text.contains("@prefix acl:"));
496            assert!(text.contains("acl:Authorization"));
497            assert!(text.contains("acl:Control"));
498            assert!(text.contains("foaf:Agent"));
499        }
500
501        #[tokio::test]
502        async fn public_type_index_acl_grants_foaf_agent_read() {
503            let (pod, outcome) = provision_default_pod().await;
504            let (body, _) = pod.get(PUBLIC_TYPE_INDEX_ACL_PATH).await.unwrap();
505            let ttl = std::str::from_utf8(&body).unwrap();
506            let doc = parse_turtle_acl(ttl).expect("ACL parses");
507            // The ACL `accessTo` is the absolute IRI of the resource.
508            // Evaluate against that same string; WAC `path_matches`
509            // normalises both sides identically.
510            let resource_iri = format!(
511                "{}{}",
512                outcome.pod_root.trim_end_matches('/'),
513                PUBLIC_TYPE_INDEX_PATH,
514            );
515
516            assert!(
517                evaluate_access(
518                    Some(&doc),
519                    None,
520                    &resource_iri,
521                    AccessMode::Read,
522                    None,
523                ),
524                "public/anonymous read must be granted on publicTypeIndex.jsonld",
525            );
526            assert!(
527                !evaluate_access(
528                    Some(&doc),
529                    None,
530                    &resource_iri,
531                    AccessMode::Write,
532                    None,
533                ),
534                "anonymous must not be granted write",
535            );
536        }
537
538        #[tokio::test]
539        async fn private_type_index_has_no_sibling_acl() {
540            let (pod, _) = provision_default_pod().await;
541            let missing = "/settings/privateTypeIndex.jsonld.acl";
542            assert!(
543                !pod.exists(missing).await.unwrap(),
544                "private type index must not have a sibling ACL; must inherit /settings/.acl",
545            );
546        }
547    }
548}