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//!
18//! ## Git auto-init (alpha.12, rows 199-200, JSS #466/#469/#471)
19//!
20//! [`GitInitHook`] is a wasm32-safe async trait. Callers that want
21//! `git init -b main` to run after the pod files are written pass an
22//! implementor (e.g. `solid_pod_rs_git::init::GitAutoInit`) to
23//! [`provision_pod_ext`]. The trait itself lives in this crate so the
24//! type is stable; the subprocess implementation lives in
25//! `solid-pod-rs-git`, which requires `tokio::process` and is never
26//! compiled for `wasm32-unknown-unknown`.
27
28use 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/// Seed plan applied to a fresh pod.
38#[derive(Debug, Clone, Deserialize, Serialize)]
39pub struct ProvisionPlan {
40    /// Pubkey (hex) that owns the pod.
41    pub pubkey: String,
42    /// Optional display name for the WebID profile.
43    #[serde(default)]
44    pub display_name: Option<String>,
45    /// Public pod base URL (used to render the WebID).
46    pub pod_base: String,
47    /// Containers to create (paths must end with `/`).
48    #[serde(default)]
49    pub containers: Vec<String>,
50    /// ACL document to drop at the pod root.
51    #[serde(default)]
52    pub root_acl: Option<AclDocument>,
53    /// Bytes quota. `None` means unlimited (but a real consumer crate
54    /// is strongly encouraged to set one).
55    #[serde(default)]
56    pub quota_bytes: Option<u64>,
57    /// **JSS v0.0.190 Phase 1 port (issue #437) — scaffolded only.**
58    ///
59    /// When `true`, the multi-user provisioning path is expected to
60    /// generate a BIP-340 Schnorr secp256k1 keypair, NIP-19 bech32
61    /// encode it, write `/private/privkey.jsonld` (owner-only WAC),
62    /// and seed the WebID `nostr:pubkey` triple. Mirrors the JSS
63    /// `POST /.pods {provisionKeys: true}` body field.
64    ///
65    /// **Not wired yet.** Setting this flag today is a no-op: the
66    /// invoking code path lives in `solid_pod_rs_idp::key_provisioning`
67    /// and its body is `todo!()`. Parity row 196.
68    #[cfg(feature = "provision-keys")]
69    #[serde(default)]
70    pub provision_keys: bool,
71}
72
73/// Result of provisioning a pod.
74#[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    /// Storage path of the public type-index resource
81    /// (`/settings/publicTypeIndex.jsonld`).
82    pub public_type_index: String,
83    /// Storage path of the private type-index resource
84    /// (`/settings/privateTypeIndex.jsonld`).
85    pub private_type_index: String,
86    /// Storage path of the ACL carve-out that grants public read on
87    /// the public type index (`/settings/publicTypeIndex.jsonld.acl`).
88    pub public_type_index_acl: String,
89}
90
91// ---------------------------------------------------------------------------
92// Type-index bootstrap helpers
93// ---------------------------------------------------------------------------
94
95/// Storage path of the public type-index document.
96pub const PUBLIC_TYPE_INDEX_PATH: &str = "/settings/publicTypeIndex.jsonld";
97
98/// Storage path of the private type-index document.
99pub const PRIVATE_TYPE_INDEX_PATH: &str = "/settings/privateTypeIndex.jsonld";
100
101/// Storage path of the sibling ACL for the public type-index document.
102pub const PUBLIC_TYPE_INDEX_ACL_PATH: &str = "/settings/publicTypeIndex.jsonld.acl";
103
104/// Render the JSON-LD body for a type-index document.
105///
106/// JSS writes the body literally (commit 54e4433, #301) with:
107/// - `@context` binding the `solid` namespace,
108/// - `@id` as the empty string (relative self-reference),
109/// - `@type` listing `solid:TypeIndex` plus either
110///   `solid:ListedDocument` (public) or `solid:UnlistedDocument`
111///   (private).
112fn 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    // Pretty-printed for human-readability on disk; clients parse either way.
119    serde_json::to_string_pretty(&body).expect("static type-index JSON always serialises")
120}
121
122/// Build the ACL document for `publicTypeIndex.jsonld` that grants:
123/// - the pod owner (`WebID`) `acl:Read`, `acl:Write`, `acl:Control`,
124/// - the public (`foaf:Agent`) `acl:Read` only.
125///
126/// The ACL sits on the resource itself (not the parent container), so
127/// it overrides the default-private `/settings/.acl`.
128fn 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
177/// Seed a pod on the provided storage.
178///
179/// * Creates every container in `plan.containers` (idempotent — the
180///   function treats `AlreadyExists` as success).
181/// * Writes a WebID profile HTML at `<pod_base>/pods/<pubkey>/profile/card`.
182/// * Writes a root ACL document if `plan.root_acl` is supplied.
183pub 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    // Ensure the pod root + default containers exist.
195    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    // Deduplicate.
200    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        // Create the `.meta` sidecar — this is the idiomatic way to
209        // materialise a bare container without a body.
210        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    // Write WebID profile.
222    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    // Write root ACL if supplied.
233    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    // -------------------------------------------------------------------
241    // Type-index bootstrap (rows 14/164/166 — JSS #301 + #297).
242    // The two `*.jsonld` bodies differ only in the visibility marker.
243    // The public one gets a sibling ACL granting `foaf:Agent` read and
244    // the owner full control; the private one inherits the default
245    // (owner-only) ACL from `/settings/.acl`, so we deliberately do
246    // *not* emit a sibling for it.
247    // -------------------------------------------------------------------
248    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    // Use an absolute resource IRI so the Turtle serialiser wraps the
267    // target in `<>` (otherwise a `.` inside the path — e.g. in
268    // `.jsonld` — trips the statement splitter on round-trip).
269    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// ---------------------------------------------------------------------------
296// Git auto-init hook (alpha.12, rows 199-200, JSS #466/#469/#471)
297// ---------------------------------------------------------------------------
298
299/// Async hook called once after pod files are written to initialise the pod
300/// directory as a git repository.
301///
302/// The trait is **wasm32-safe** — it carries no subprocess dependency.
303/// The concrete implementation (`solid_pod_rs_git::init::GitAutoInit`)
304/// spawns `git init -b main` via `tokio::process::Command` and lives in
305/// a separate crate that is never compiled for `wasm32-unknown-unknown`.
306///
307/// Pass an implementor to [`provision_pod_ext`] to opt in.
308/// Feature-gated: only present when the `git-auto-init` Cargo feature
309/// is enabled on `solid-pod-rs`.
310#[cfg(feature = "git-auto-init")]
311#[async_trait::async_trait]
312pub trait GitInitHook: Send + Sync {
313    /// Called with the absolute filesystem path of the freshly provisioned
314    /// pod directory. Errors are **logged and swallowed** — a git-init
315    /// failure must not roll back or prevent pod creation.
316    async fn try_init_repo(&self, fs_pod_root: &std::path::Path) -> Result<(), PodError>;
317}
318
319/// Like [`provision_pod`], but accepts an optional git-init hook that runs
320/// after all pod files are written.
321///
322/// `git_hook` is `Option<(&H, &Path)>` where the `Path` is the absolute
323/// filesystem root of the pod directory (e.g.
324/// `/var/lib/pods/<pubkey>/`). Required because `provision_pod` writes
325/// through an abstract `Storage` and does not itself know the on-disk
326/// path. Callers using `fs-backend` construct this path from the same
327/// root they passed to `FsBackend::new`.
328///
329/// When `git_hook` is `None` this is identical to `provision_pod`.
330#[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// ---------------------------------------------------------------------------
358// Quota enforcement
359// ---------------------------------------------------------------------------
360
361/// Tracks per-pod byte usage against a configurable quota.
362#[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    /// Bytes currently accounted for.
384    pub fn used(&self) -> u64 {
385        self.used_bytes.load(std::sync::atomic::Ordering::Relaxed)
386    }
387
388    /// Configured quota, if any.
389    pub fn quota(&self) -> Option<u64> {
390        self.quota_bytes
391    }
392
393    /// Reserve `size` bytes. Returns `Err(PodError::PreconditionFailed)`
394    /// when the operation would exceed the quota, without mutating the
395    /// tracker.
396    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    /// Release `size` bytes previously reserved (e.g. on DELETE).
411    pub fn release(&self, size: u64) {
412        self.used_bytes
413            .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Admin override
419// ---------------------------------------------------------------------------
420
421/// A verified admin-override marker. The consumer crate constructs this
422/// only after validating a shared-secret header against configuration;
423/// the marker carries no data beyond its own existence.
424#[derive(Debug, Clone, Copy)]
425pub struct AdminOverride;
426
427/// Match an admin-secret header value against the configured secret.
428/// Both sides are compared with constant-time equality to avoid
429/// timing leaks. Returns `Some(AdminOverride)` on match.
430pub 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// ---------------------------------------------------------------------------
451// Tests
452// ---------------------------------------------------------------------------
453
454#[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    // -------------------------------------------------------------------
494    // Type-index bootstrap tests (rows 14/164/166).
495    // -------------------------------------------------------------------
496    #[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            // The ACL `accessTo` is the absolute IRI of the resource.
593            // Evaluate against that same string; WAC `path_matches`
594            // normalises both sides identically.
595            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}