qualia_client_core/companion_bundle/
mod.rs1use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::BTreeMap;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12pub const BUNDLE_SCHEMA_VERSION: u32 = 1;
13pub const COMPANION_PROFILE_NAME: &str = "wellfair-linked-companion";
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct BundleFileEntry {
17 pub path: String,
18 pub sha256: String,
19 pub size_bytes: u64,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct CompanionBundleManifest {
24 pub schema_version: u32,
25 pub package_id: String,
26 pub version: String,
27 pub profile: String,
28 pub host_api_version: String,
29 pub abi_version: String,
30 pub content_hash: String,
31 pub files: Vec<BundleFileEntry>,
32 #[serde(default, skip_serializing_if = "String::is_empty")]
33 pub signature_hex: String,
34}
35
36#[derive(Debug, PartialEq, Eq)]
37pub enum BundleBuildError {
38 MissingInput(String),
39 Io(String),
40 InvalidManifest(String),
41}
42
43impl std::fmt::Display for BundleBuildError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::MissingInput(p) => write!(f, "missing input: {p}"),
47 Self::Io(e) => write!(f, "io error: {e}"),
48 Self::InvalidManifest(e) => write!(f, "invalid manifest: {e}"),
49 }
50 }
51}
52
53impl From<io::Error> for BundleBuildError {
54 fn from(value: io::Error) -> Self {
55 Self::Io(value.to_string())
56 }
57}
58
59fn sha256_hex(bytes: &[u8]) -> String {
60 hex::encode(Sha256::digest(bytes).as_slice())
61}
62
63pub fn hash_bundle_tree(root: &Path) -> Result<(String, Vec<BundleFileEntry>), BundleBuildError> {
65 let mut entries = Vec::new();
66 collect_files(root, root, &mut entries)?;
67 entries.sort_by(|a, b| a.path.cmp(&b.path));
68
69 let aggregate = entries
70 .iter()
71 .map(|e| format!("{}:{}:{}", e.path, e.sha256, e.size_bytes))
72 .collect::<Vec<_>>()
73 .join("\n");
74 let content_hash = sha256_hex(aggregate.as_bytes());
75 Ok((content_hash, entries))
76}
77
78fn collect_files(
79 root: &Path,
80 current: &Path,
81 out: &mut Vec<BundleFileEntry>,
82) -> Result<(), BundleBuildError> {
83 for entry in fs::read_dir(current)? {
84 let entry = entry?;
85 let path = entry.path();
86 if path.is_dir() {
87 collect_files(root, &path, out)?;
88 } else {
89 let rel = path
90 .strip_prefix(root)
91 .map_err(|_| BundleBuildError::InvalidManifest(path.display().to_string()))?
92 .to_string_lossy()
93 .replace('\\', "/");
94 let bytes = fs::read(&path)?;
95 out.push(BundleFileEntry {
96 path: rel,
97 sha256: sha256_hex(&bytes),
98 size_bytes: bytes.len() as u64,
99 });
100 }
101 }
102 Ok(())
103}
104
105#[derive(Debug, Clone)]
106pub struct CompanionBundleInput {
107 pub package_id: String,
108 pub version: String,
109 pub wasm_js: PathBuf,
110 pub wasm_binary: PathBuf,
111 pub index_html: PathBuf,
112 pub qapp_json: PathBuf,
113}
114
115pub fn build_companion_bundle(
117 output_dir: &Path,
118 input: &CompanionBundleInput,
119) -> Result<CompanionBundleManifest, BundleBuildError> {
120 for required in [
121 &input.wasm_js,
122 &input.wasm_binary,
123 &input.index_html,
124 &input.qapp_json,
125 ] {
126 if !required.is_file() {
127 return Err(BundleBuildError::MissingInput(
128 required.display().to_string(),
129 ));
130 }
131 }
132
133 if output_dir.exists() {
134 fs::remove_dir_all(output_dir)?;
135 }
136 fs::create_dir_all(output_dir.join("wasm"))?;
137 fs::create_dir_all(output_dir.join("assets"))?;
138
139 fs::copy(&input.qapp_json, output_dir.join("qapp.json"))?;
140 fs::copy(&input.index_html, output_dir.join("index.html"))?;
141 fs::copy(
142 &input.wasm_js,
143 output_dir
144 .join("wasm")
145 .join(input.wasm_js.file_name().unwrap()),
146 )?;
147 fs::copy(
148 &input.wasm_binary,
149 output_dir
150 .join("wasm")
151 .join(input.wasm_binary.file_name().unwrap()),
152 )?;
153
154 let (content_hash, files) = hash_bundle_tree(output_dir)?;
155 let manifest = CompanionBundleManifest {
156 schema_version: BUNDLE_SCHEMA_VERSION,
157 package_id: input.package_id.clone(),
158 version: input.version.clone(),
159 profile: COMPANION_PROFILE_NAME.to_string(),
160 host_api_version: crate::qapp_install::SUPPORTED_HOST_API_VERSION.to_string(),
161 abi_version: crate::qapp_install::SUPPORTED_QAPP_ABI_VERSION.to_string(),
162 content_hash: content_hash.clone(),
163 files,
164 signature_hex: String::new(),
165 };
166
167 let json = serde_json::to_string_pretty(&manifest)
168 .map_err(|e| BundleBuildError::InvalidManifest(e.to_string()))?;
169 fs::write(output_dir.join("package-manifest.json"), &json)?;
170 let cbor = serde_json::to_vec(&manifest)
171 .map_err(|e| BundleBuildError::InvalidManifest(e.to_string()))?;
172 fs::write(output_dir.join("package-manifest.cbor"), cbor)?;
173
174 Ok(manifest)
175}
176
177pub fn verify_companion_bundle(
179 bundle_dir: &Path,
180) -> Result<CompanionBundleManifest, BundleBuildError> {
181 let manifest_path = bundle_dir.join("package-manifest.json");
182 if !manifest_path.is_file() {
183 return Err(BundleBuildError::MissingInput(
184 "package-manifest.json".into(),
185 ));
186 }
187 let content = fs::read_to_string(&manifest_path)?;
188 let manifest: CompanionBundleManifest = serde_json::from_str(&content)
189 .map_err(|e| BundleBuildError::InvalidManifest(e.to_string()))?;
190
191 let mut on_disk: BTreeMap<String, BundleFileEntry> = BTreeMap::new();
192 let (_, scanned) = hash_bundle_tree(bundle_dir)?;
193 for entry in scanned {
194 if entry.path == "package-manifest.json" || entry.path == "package-manifest.cbor" {
195 continue;
196 }
197 on_disk.insert(entry.path.clone(), entry);
198 }
199
200 for expected in &manifest.files {
201 if expected.path == "package-manifest.json" || expected.path == "package-manifest.cbor" {
202 continue;
203 }
204 let actual = on_disk
205 .get(&expected.path)
206 .ok_or_else(|| BundleBuildError::MissingInput(expected.path.clone()))?;
207 if actual.sha256 != expected.sha256 || actual.size_bytes != expected.size_bytes {
208 return Err(BundleBuildError::InvalidManifest(format!(
209 "hash mismatch for {}",
210 expected.path
211 )));
212 }
213 }
214
215 let mut payload_entries: Vec<BundleFileEntry> = manifest.files.clone();
216 payload_entries.sort_by(|a, b| a.path.cmp(&b.path));
217 let aggregate = payload_entries
218 .iter()
219 .map(|e| format!("{}:{}:{}", e.path, e.sha256, e.size_bytes))
220 .collect::<Vec<_>>()
221 .join("\n");
222 let recomputed = sha256_hex(aggregate.as_bytes());
223 if recomputed != manifest.content_hash {
224 return Err(BundleBuildError::InvalidManifest(
225 "content_hash does not match bundle tree".into(),
226 ));
227 }
228
229 Ok(manifest)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use std::time::{SystemTime, UNIX_EPOCH};
236
237 fn temp_dir(label: &str) -> PathBuf {
238 let nanos = SystemTime::now()
239 .duration_since(UNIX_EPOCH)
240 .unwrap()
241 .as_nanos();
242 std::env::temp_dir().join(format!("qualia-bundle-{label}-{nanos}"))
243 }
244
245 #[test]
246 fn deterministic_content_hash_for_same_inputs() {
247 let root = temp_dir("build");
248 let out_a = root.join("out-a");
249 let out_b = root.join("out-b");
250 let src = root.join("src");
251 fs::create_dir_all(&src).unwrap();
252 fs::write(
253 src.join("qapp.json"),
254 r#"{"name":"wellfair","version":"0.0.24","required_shapes":[]}"#,
255 )
256 .unwrap();
257 fs::write(src.join("index.html"), "<html>wellfair</html>").unwrap();
258 fs::write(src.join("profile.js"), "export default {}").unwrap();
259 fs::write(src.join("profile_bg.wasm"), b"\0asm").unwrap();
260
261 let input = CompanionBundleInput {
262 package_id: "wellfair-companion".into(),
263 version: "0.0.24".into(),
264 wasm_js: src.join("profile.js"),
265 wasm_binary: src.join("profile_bg.wasm"),
266 index_html: src.join("index.html"),
267 qapp_json: src.join("qapp.json"),
268 };
269
270 let m_a = build_companion_bundle(&out_a, &input).unwrap();
271 let m_b = build_companion_bundle(&out_b, &input).unwrap();
272 assert_eq!(m_a.content_hash, m_b.content_hash);
273 verify_companion_bundle(&out_a).unwrap();
274 let _ = fs::remove_dir_all(&root);
275 }
276}