Skip to main content

qualia_client_core/companion_bundle/
mod.rs

1//! Deterministic WellFair companion WASM/PWA bundle builder (Workstream 3, M2).
2//!
3//! Consumes a prebuilt signed WASM artifact and emits a reproducible package tree.
4
5use 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
63/// Walk `root` deterministically and hash every file (sorted paths).
64pub 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
115/// Assemble bundle directory with deterministic ordering and manifest emission.
116pub 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
177/// Verify an on-disk bundle matches its embedded manifest hashes.
178pub 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}