1use std::collections::HashMap;
13
14use anyhow::{Context, Result};
15use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
16
17pub 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 let err = decode_bundle_files(r#"{ "files": 42 }"#).unwrap_err();
96 assert!(err.to_string().contains("path→base64"), "{err}");
97 }
98}