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