Skip to main content

qualia_client_core/wellfair/
qapp_publish.rs

1//! WP2 — Package & Publish: turn a `QappManifest` into an installable PWA bundle on disk.
2//!
3//! Reuses [`qualia_cooperative_core::qapp_package::generate_pwa`] (the P0 foundation) and writes
4//! the generated bundle to a caller-chosen directory, path-traversal-safe. Producing the bytes is
5//! WP2; **serving** them over a secure origin so a phone can install is a later stage (P1, see the
6//! companion-PWA plan). This module is the authoring → artifact bridge.
7
8#![cfg(not(target_arch = "wasm32"))]
9
10use std::path::{Component, Path};
11
12use qualia_cooperative_core::qapp_package::{
13    generate_pwa, Capability, IconRef, PwaContent, QappKind, QappManifest, WasmRef,
14};
15
16/// Parse a kind string to a [`QappKind`] (extensible: unknown → `Custom`).
17pub fn parse_kind(s: &str) -> QappKind {
18    match s.trim().to_ascii_lowercase().as_str() {
19        "cooperative" => QappKind::Cooperative,
20        "health" => QappKind::Health,
21        "journal" => QappKind::Journal,
22        "directory" => QappKind::Directory,
23        "" => QappKind::Custom("custom".to_string()),
24        other => QappKind::Custom(other.to_string()),
25    }
26}
27
28/// Parse a capability token to a [`Capability`] (extensible: unknown → `Custom`).
29pub fn parse_capability(s: &str) -> Capability {
30    match s.trim().to_ascii_lowercase().as_str() {
31        "read_records" | "readrecords" => Capability::ReadRecords,
32        "write_records" | "writerecords" => Capability::WriteRecords,
33        "sync" => Capability::Sync,
34        "blob_store" | "blobstore" => Capability::BlobStore,
35        "notifications" => Capability::Notifications,
36        "camera" => Capability::Camera,
37        other => Capability::Custom(other.to_string()),
38    }
39}
40
41/// Build a [`QappManifest`] from discrete author-supplied fields. The wasm bundle is referenced by
42/// filename only (its hash/size are filled by the build stage, P3); an empty `wasm_filename`
43/// defaults to `app.wasm`.
44pub fn build_manifest(
45    id: &str,
46    name: &str,
47    kind: &str,
48    description: &str,
49    capabilities_csv: &str,
50    wasm_filename: &str,
51) -> QappManifest {
52    let wasm = if wasm_filename.trim().is_empty() {
53        "app.wasm".to_string()
54    } else {
55        wasm_filename.trim().to_string()
56    };
57    let mut manifest = QappManifest::new(id, name)
58        .with_kind(parse_kind(kind))
59        .with_description(description)
60        .with_entry(WasmRef {
61            path: wasm,
62            sha256_hex: String::new(),
63            size_bytes: 0,
64        })
65        // A PWA needs an icon to be installable; reference one the author drops alongside the
66        // bundle (like the wasm entry). Without it, manifest validation fails.
67        .with_icon(IconRef {
68            src: "icon-512.png".to_string(),
69            sizes: "512x512".to_string(),
70            purpose: "any".to_string(),
71        });
72    for cap in capabilities_csv
73        .split(',')
74        .map(|c| c.trim())
75        .filter(|c| !c.is_empty())
76    {
77        manifest = manifest.with_capability(parse_capability(cap));
78    }
79    manifest
80}
81
82/// A bundle-relative path is safe iff it is relative and contains no `..`/root components.
83fn is_safe_relative(path: &str) -> bool {
84    let p = Path::new(path);
85    !p.is_absolute()
86        && p.components()
87            .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
88}
89
90/// Generate the PWA bundle for `manifest` and write every file under `target_dir`. Returns the list
91/// of written (bundle-relative) paths. Rejects an invalid manifest and any unsafe bundle path.
92pub fn write_pwa_bundle(target_dir: &Path, manifest: &QappManifest) -> Result<Vec<String>, String> {
93    if let Err(problems) = manifest.validate() {
94        return Err(format!("Invalid manifest: {}", problems.join("; ")));
95    }
96    let bundle = generate_pwa(manifest);
97    let mut written = Vec::new();
98    for file in &bundle.files {
99        if !is_safe_relative(&file.path) {
100            return Err(format!("Unsafe bundle path rejected: {}", file.path));
101        }
102        let dest = target_dir.join(&file.path);
103        if let Some(parent) = dest.parent() {
104            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
105        }
106        match &file.content {
107            PwaContent::Text(s) => {
108                std::fs::write(&dest, s.as_bytes()).map_err(|e| e.to_string())?
109            }
110            PwaContent::Bytes(b) => std::fs::write(&dest, b).map_err(|e| e.to_string())?,
111        }
112        written.push(file.path.clone());
113    }
114    Ok(written)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use tempfile::tempdir;
121
122    #[test]
123    fn build_manifest_maps_kind_and_capabilities() {
124        let m = build_manifest(
125            "coop.qualia.journal",
126            "Journal",
127            "journal",
128            "a private journal",
129            "read_records, write_records, sync",
130            "journal.wasm",
131        );
132        assert_eq!(m.kind, QappKind::Journal);
133        assert_eq!(m.capabilities.len(), 3);
134        assert!(m.capabilities.contains(&Capability::WriteRecords));
135        assert_eq!(m.entry_wasm.path, "journal.wasm");
136        // Unknown kind → Custom (extensible).
137        assert_eq!(
138            build_manifest("x.y", "Z", "bespoke-thing", "", "", "").kind,
139            QappKind::Custom("bespoke-thing".to_string())
140        );
141    }
142
143    #[test]
144    fn write_bundle_emits_installable_scaffold() {
145        let dir = tempdir().unwrap();
146        let manifest = build_manifest(
147            "coop.qualia.demo",
148            "Demo",
149            "cooperative",
150            "demo qapp",
151            "read_records",
152            "app.wasm",
153        );
154        let written = write_pwa_bundle(dir.path(), &manifest).unwrap();
155        for expected in ["manifest.webmanifest", "sw.js", "index.html"] {
156            assert!(
157                written.iter().any(|p| p == expected),
158                "missing {expected} in {written:?}"
159            );
160            assert!(dir.path().join(expected).exists(), "{expected} not on disk");
161        }
162        // The manifest is a real Web App Manifest.
163        let webmanifest = std::fs::read_to_string(dir.path().join("manifest.webmanifest")).unwrap();
164        assert!(webmanifest.contains("\"display\""));
165        assert!(webmanifest.contains("Demo"));
166    }
167
168    #[test]
169    fn rejects_unsafe_paths() {
170        assert!(is_safe_relative("index.html"));
171        assert!(is_safe_relative("icons/app.png"));
172        assert!(!is_safe_relative("../escape"));
173        assert!(!is_safe_relative("/etc/passwd"));
174    }
175
176    #[test]
177    fn invalid_manifest_is_rejected() {
178        let dir = tempdir().unwrap();
179        // Empty id → validation failure (no dot / empty).
180        let bad = build_manifest("", "", "journal", "", "", "");
181        assert!(write_pwa_bundle(dir.path(), &bad).is_err());
182    }
183}