Skip to main content

dotzuki_runner/
bundle.rs

1//! Game bundle decoding: the legacy JSON sibling of the `.dzpk` pack.
2//!
3//! Older `dotzuki export` builds wrote `game.bundle.json` as
4//! `{ "dotzuki": {…export metadata…}, "files": { "<path>": "<base64>" } }`.
5//! Current exports ship a binary `.dzpk` pack instead (see [`crate::pack`]) —
6//! base64 inflates assets ~1.33× and costs a full decode on boot. This module
7//! stays so players can still boot an old export: [`decode_bundle_files`]
8//! accepts BOTH shapes — a top-level object with a `files` object member is
9//! unwrapped, anything else must itself be the files map. The `dotzuki`
10//! metadata is informational only.
11
12use std::collections::HashMap;
13
14use anyhow::{Context, Result};
15use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
16
17/// Decode a bundle JSON string into the `path → content` map a
18/// [`MemoryFiles`](crate::vfs::MemoryFiles) boots from.
19///
20/// Accepts the full `game.bundle.json` (`{ "dotzuki": …, "files": … }`) or a
21/// bare `{ path: base64 }` files map.
22///
23/// # Errors
24///
25/// Fails when the JSON is malformed, when neither shape yields a
26/// `path → base64-string` object, or when a value is not valid base64 (the
27/// error names the offending file).
28pub fn decode_bundle_files(bundle_json: &str) -> Result<HashMap<String, Vec<u8>>> {
29    let value: serde_json::Value =
30        serde_json::from_str(bundle_json).context("bundle is not valid JSON")?;
31    let files_value = match &value {
32        serde_json::Value::Object(map) => match map.get("files") {
33            Some(files @ serde_json::Value::Object(_)) => files,
34            _ => &value,
35        },
36        _ => &value,
37    };
38    let encoded: HashMap<String, String> = serde_json::from_value(files_value.clone())
39        .context("bundle is not an object of path→base64 strings")?;
40    let mut files = HashMap::with_capacity(encoded.len());
41    for (path, b64) in encoded {
42        let bytes = BASE64
43            .decode(&b64)
44            .with_context(|| format!("file '{path}': invalid base64"))?;
45        files.insert(path, bytes);
46    }
47    Ok(files)
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn decodes_a_bare_files_map() {
56        let json = serde_json::json!({
57            "a/b.txt": BASE64.encode(b"hello"),
58            "c.bin": BASE64.encode([0, 1, 2, 255]),
59        })
60        .to_string();
61        let files = decode_bundle_files(&json).unwrap();
62        assert_eq!(files["a/b.txt"], b"hello");
63        assert_eq!(files["c.bin"], vec![0, 1, 2, 255]);
64    }
65
66    #[test]
67    fn unwraps_a_full_bundle_and_ignores_metadata() {
68        let json = serde_json::json!({
69            "dotzuki": { "tool": "dotzuki-cli", "version": "0.0.0", "exportedAt": 0 },
70            "files": { "a.txt": BASE64.encode(b"hi") },
71        })
72        .to_string();
73        let files = decode_bundle_files(&json).unwrap();
74        assert_eq!(files.len(), 1);
75        assert_eq!(files["a.txt"], b"hi");
76    }
77
78    #[test]
79    fn reports_the_file_with_bad_base64() {
80        let json = serde_json::json!({ "data/x.png": "!!! not base64 !!!" }).to_string();
81        let err = decode_bundle_files(&json).unwrap_err();
82        assert!(err.to_string().contains("data/x.png"), "{err}");
83    }
84
85    #[test]
86    fn rejects_non_object_json() {
87        let err = decode_bundle_files(r#"["not", "an", "object"]"#).unwrap_err();
88        assert!(err.to_string().contains("path→base64"), "{err}");
89    }
90
91    #[test]
92    fn a_non_object_files_member_falls_back_to_the_whole_object() {
93        // `{ "files": 42 }` is not a bundle wrapper, so the whole object is
94        // treated as the files map — and fails because 42 is not base64.
95        let err = decode_bundle_files(r#"{ "files": 42 }"#).unwrap_err();
96        assert!(err.to_string().contains("path→base64"), "{err}");
97    }
98}