qualia_client_core/wellfair/
qapp_publish.rs1#![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
16pub 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
28pub 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
41pub 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 .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
82fn 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
90pub 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 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 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 let bad = build_manifest("", "", "journal", "", "", "");
181 assert!(write_pwa_bundle(dir.path(), &bad).is_err());
182 }
183}