1use std::collections::BTreeMap;
27
28use anyhow::{bail, Context, Result};
29
30use crate::vfs::ProjectFiles;
31
32pub const MAGIC: [u8; 4] = *b"DZPK";
34pub const FORMAT_VERSION: u32 = 1;
36const HEADER_LEN: usize = 12;
38
39#[derive(serde::Deserialize, serde::Serialize)]
41struct IndexEntry {
42 offset: u64,
43 size: u64,
44}
45
46#[derive(serde::Deserialize, serde::Serialize)]
48struct Index {
49 #[serde(default)]
51 #[allow(dead_code)]
52 dotzuki: serde_json::Value,
53 files: BTreeMap<String, IndexEntry>,
54}
55
56pub fn encode_pack(files: &BTreeMap<String, Vec<u8>>, dotzuki_meta: serde_json::Value) -> Vec<u8> {
60 let mut index_files = BTreeMap::new();
61 let mut data_len: u64 = 0;
62 let mut offset: u64 = 0;
63 for (path, bytes) in files {
64 index_files.insert(
65 path.clone(),
66 IndexEntry {
67 offset,
68 size: bytes.len() as u64,
69 },
70 );
71 offset += bytes.len() as u64;
72 data_len = offset;
73 }
74 let index = Index {
75 dotzuki: dotzuki_meta,
76 files: index_files,
77 };
78 let index_json = serde_json::to_vec(&index).expect("pack index serialization is infallible");
80
81 let mut out = Vec::with_capacity(HEADER_LEN + index_json.len() + data_len as usize);
82 out.extend_from_slice(&MAGIC);
83 out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
84 out.extend_from_slice(&(index_json.len() as u32).to_le_bytes());
85 out.extend_from_slice(&index_json);
86 for bytes in files.values() {
87 out.extend_from_slice(bytes);
88 }
89 out
90}
91
92pub struct PackFiles {
97 bytes: Vec<u8>,
98 data_start: usize,
100 entries: BTreeMap<String, (u64, u64)>,
102}
103
104impl PackFiles {
105 pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
114 if bytes.len() < HEADER_LEN {
115 bail!("not a .dzpk pack: file is shorter than the {HEADER_LEN}-byte header");
116 }
117 if bytes[0..4] != MAGIC {
118 bail!("not a .dzpk pack: bad magic (expected DZPK)");
119 }
120 let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
121 if version != FORMAT_VERSION {
122 bail!("unsupported .dzpk format version {version} (this player reads {FORMAT_VERSION})");
123 }
124 let index_len = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
125 let data_start = HEADER_LEN + index_len;
126 if bytes.len() < data_start {
127 bail!("corrupt .dzpk pack: index is truncated");
128 }
129 let index: Index = serde_json::from_slice(&bytes[HEADER_LEN..data_start])
130 .context("corrupt .dzpk pack: index is not valid JSON")?;
131 let data_len = (bytes.len() - data_start) as u64;
132 let mut entries = BTreeMap::new();
133 for (path, entry) in index.files {
134 let end = entry.offset.checked_add(entry.size).with_context(|| {
135 format!("corrupt .dzpk pack: file '{path}' range overflows")
136 })?;
137 if end > data_len {
138 bail!(
139 "corrupt .dzpk pack: file '{path}' (offset {}, size {}) points past the data section ({data_len} bytes)",
140 entry.offset,
141 entry.size
142 );
143 }
144 entries.insert(path, (entry.offset, entry.size));
145 }
146 Ok(Self {
147 bytes,
148 data_start,
149 entries,
150 })
151 }
152}
153
154impl std::fmt::Debug for PackFiles {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.debug_struct("PackFiles")
157 .field("files", &self.entries.len())
158 .field("bytes", &self.bytes.len())
159 .finish()
160 }
161}
162
163impl ProjectFiles for PackFiles {
164 fn read(&self, path: &str) -> Result<Vec<u8>> {
165 let (offset, size) = self
166 .entries
167 .get(path)
168 .with_context(|| format!("no such file '{path}'"))?;
169 let start = self.data_start + *offset as usize;
170 Ok(self.bytes[start..start + *size as usize].to_vec())
171 }
172
173 fn list(&self, prefix: &str) -> Vec<String> {
174 self.entries
176 .keys()
177 .filter(|k| {
178 prefix.is_empty()
179 || k.as_str() == prefix
180 || k.strip_prefix(prefix)
181 .is_some_and(|rest| rest.starts_with('/'))
182 })
183 .cloned()
184 .collect()
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn sample_files() -> BTreeMap<String, Vec<u8>> {
193 BTreeMap::from([
194 ("data/a.txt".to_string(), b"hello".to_vec()),
195 ("data/deep/b.bin".to_string(), vec![0, 1, 2, 255]),
196 (".dotzuki-editor.json".to_string(), b"{}".to_vec()),
197 ])
198 }
199
200 fn sample_pack() -> Vec<u8> {
201 encode_pack(&sample_files(), serde_json::json!({"tool": "test", "version": "0.0.0"}))
202 }
203
204 #[test]
205 fn round_trip_reads_every_file_byte_exact() {
206 let pack = PackFiles::from_bytes(sample_pack()).unwrap();
207 for (path, bytes) in sample_files() {
208 assert_eq!(pack.read(&path).unwrap(), bytes, "{path}");
209 }
210 assert!(pack.read("nope").is_err());
211 assert!(!pack.exists("nope"));
212 assert!(pack.exists("data/a.txt"));
213 }
214
215 #[test]
216 fn list_is_sorted_and_prefix_bounded() {
217 let pack = PackFiles::from_bytes(sample_pack()).unwrap();
218 assert_eq!(
219 pack.list("data"),
220 vec!["data/a.txt".to_string(), "data/deep/b.bin".to_string()]
221 );
222 assert_eq!(pack.list("datas"), Vec::<String>::new());
223 assert_eq!(pack.list("").len(), 3);
224 }
225
226 #[test]
227 fn encoding_is_deterministic() {
228 assert_eq!(sample_pack(), sample_pack());
229 }
230
231 #[test]
232 fn rejects_a_short_buffer() {
233 let err = PackFiles::from_bytes(b"DZ".to_vec()).unwrap_err();
234 assert!(err.to_string().contains("shorter than"), "{err}");
235 }
236
237 #[test]
238 fn rejects_bad_magic() {
239 let mut pack = sample_pack();
240 pack[0] = b'X';
241 let err = PackFiles::from_bytes(pack).unwrap_err();
242 assert!(err.to_string().contains("bad magic"), "{err}");
243 }
244
245 #[test]
246 fn rejects_an_unknown_version() {
247 let mut pack = sample_pack();
248 pack[4..8].copy_from_slice(&99u32.to_le_bytes());
249 let err = PackFiles::from_bytes(pack).unwrap_err();
250 assert!(err.to_string().contains("version 99"), "{err}");
251 }
252
253 #[test]
254 fn rejects_a_truncated_index() {
255 let mut pack = sample_pack();
256 let index_len = u32::from_le_bytes(pack[8..12].try_into().unwrap()) as usize;
257 pack[8..12].copy_from_slice(&(index_len as u32 + 16).to_le_bytes());
258 let err = PackFiles::from_bytes(pack).unwrap_err();
259 assert!(err.to_string().contains("truncated"), "{err}");
260 }
261
262 #[test]
263 fn rejects_an_entry_past_the_data_section() {
264 let files = BTreeMap::from([("a.txt".to_string(), b"hi".to_vec())]);
265 let mut pack = encode_pack(&files, serde_json::json!({}));
266 let index_json = br#"{"dotzuki":{},"files":{"a.txt":{"offset":0,"size":100}}}"#;
268 pack.truncate(HEADER_LEN);
269 pack[8..12].copy_from_slice(&(index_json.len() as u32).to_le_bytes());
270 pack.extend_from_slice(index_json);
271 pack.extend_from_slice(b"hi");
272 let err = PackFiles::from_bytes(pack).unwrap_err();
273 assert!(err.to_string().contains("a.txt"), "{err}");
274 assert!(err.to_string().contains("past the data section"), "{err}");
275 }
276
277 #[test]
278 fn an_empty_pack_boots_an_empty_file_set() {
279 let pack = PackFiles::from_bytes(encode_pack(&BTreeMap::new(), serde_json::json!({})))
280 .unwrap();
281 assert_eq!(pack.list(""), Vec::<String>::new());
282 assert!(pack.read("anything").is_err());
283 }
284}