1#![allow(dead_code)]
5
6use std::fs;
25use std::io::Write;
26use std::path::Path;
27
28use anyhow::{anyhow, bail, Context, Result};
29
30pub const OXB_MAGIC: &[u8; 4] = b"OXB1";
32
33pub const MAX_ENTRY_NAME: usize = 63;
35
36const HEADER_SIZE: usize = 16;
37const DIR_ENTRY_SIZE: usize = 72;
38const NAME_FIELD_SIZE: usize = 64;
39
40pub struct BundleEntry {
44 pub name: String,
45 pub data: Vec<u8>,
46}
47
48impl BundleEntry {
49 pub fn new(name: impl Into<String>, data: Vec<u8>) -> Result<Self> {
54 let name = name.into();
55 validate_name(&name)?;
56 Ok(Self { name, data })
57 }
58
59 pub fn from_file(name: impl Into<String>, path: &Path) -> Result<Self> {
61 let name = name.into();
62 validate_name(&name)?;
63 let data =
64 fs::read(path).with_context(|| format!("failed to read file: {}", path.display()))?;
65 Ok(Self { name, data })
66 }
67
68 pub fn size(&self) -> usize {
70 self.data.len()
71 }
72}
73
74pub struct AssetBundle {
79 entries: Vec<BundleEntry>,
80}
81
82impl AssetBundle {
83 pub fn new() -> Self {
85 Self {
86 entries: Vec::new(),
87 }
88 }
89
90 pub fn add(&mut self, entry: BundleEntry) -> Result<()> {
94 if self.contains(&entry.name) {
95 bail!("duplicate entry name: {}", entry.name);
96 }
97 self.entries.push(entry);
98 Ok(())
99 }
100
101 pub fn add_bytes(&mut self, name: impl Into<String>, data: Vec<u8>) -> Result<()> {
103 let entry = BundleEntry::new(name, data)?;
104 self.add(entry)
105 }
106
107 pub fn add_file(&mut self, name: impl Into<String>, path: &Path) -> Result<()> {
109 let entry = BundleEntry::from_file(name, path)?;
110 self.add(entry)
111 }
112
113 pub fn add_str(&mut self, name: impl Into<String>, text: &str) -> Result<()> {
115 self.add_bytes(name, text.as_bytes().to_vec())
116 }
117
118 pub fn entry_count(&self) -> usize {
120 self.entries.len()
121 }
122
123 pub fn total_size(&self) -> usize {
125 self.entries.iter().map(|e| e.size()).sum()
126 }
127
128 pub fn get(&self, name: &str) -> Option<&BundleEntry> {
130 self.entries.iter().find(|e| e.name == name)
131 }
132
133 pub fn entry_names(&self) -> Vec<&str> {
135 self.entries.iter().map(|e| e.name.as_str()).collect()
136 }
137
138 pub fn contains(&self, name: &str) -> bool {
140 self.entries.iter().any(|e| e.name == name)
141 }
142
143 pub fn remove(&mut self, name: &str) -> bool {
145 if let Some(pos) = self.entries.iter().position(|e| e.name == name) {
146 self.entries.remove(pos);
147 true
148 } else {
149 false
150 }
151 }
152}
153
154impl Default for AssetBundle {
155 fn default() -> Self {
156 Self::new()
157 }
158}
159
160pub fn export_bundle(bundle: &AssetBundle, path: &Path) -> Result<()> {
164 let entry_count = bundle.entries.len();
165 let data_offset = HEADER_SIZE + entry_count * DIR_ENTRY_SIZE;
166
167 let mut file = fs::File::create(path)
168 .with_context(|| format!("cannot create bundle file: {}", path.display()))?;
169
170 file.write_all(OXB_MAGIC)?;
172 file.write_all(&(entry_count as u32).to_le_bytes())?;
173 file.write_all(&[0u8; 8])?; let mut current_offset = data_offset as u32;
177 for entry in &bundle.entries {
178 let mut name_buf = [0u8; NAME_FIELD_SIZE];
179 let name_bytes = entry.name.as_bytes();
180 name_buf[..name_bytes.len()].copy_from_slice(name_bytes);
181 file.write_all(&name_buf)?;
182 file.write_all(¤t_offset.to_le_bytes())?;
183 file.write_all(&(entry.data.len() as u32).to_le_bytes())?;
184 current_offset += entry.data.len() as u32;
185 }
186
187 for entry in &bundle.entries {
189 file.write_all(&entry.data)?;
190 }
191
192 file.flush()?;
193 Ok(())
194}
195
196pub fn load_bundle(path: &Path) -> Result<AssetBundle> {
198 let raw =
199 fs::read(path).with_context(|| format!("cannot read bundle file: {}", path.display()))?;
200
201 if raw.len() < HEADER_SIZE {
202 bail!("bundle file too small to contain a valid header");
203 }
204
205 if &raw[0..4] != OXB_MAGIC.as_ref() {
207 bail!("invalid OXB magic bytes");
208 }
209 let entry_count = u32::from_le_bytes(
210 raw[4..8]
211 .try_into()
212 .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
213 ) as usize;
214
215 let dir_end = HEADER_SIZE + entry_count * DIR_ENTRY_SIZE;
216 if raw.len() < dir_end {
217 bail!("bundle file truncated: directory extends past end of file");
218 }
219
220 let mut bundle = AssetBundle::new();
222 for i in 0..entry_count {
223 let base = HEADER_SIZE + i * DIR_ENTRY_SIZE;
224 let name_field = &raw[base..base + NAME_FIELD_SIZE];
225 let null_pos = name_field
226 .iter()
227 .position(|&b| b == 0)
228 .unwrap_or(NAME_FIELD_SIZE);
229 let name = std::str::from_utf8(&name_field[..null_pos])
230 .with_context(|| format!("entry {} has invalid UTF-8 name", i))?
231 .to_owned();
232
233 let offset = u32::from_le_bytes(
234 raw[base + 64..base + 68]
235 .try_into()
236 .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
237 ) as usize;
238 let length = u32::from_le_bytes(
239 raw[base + 68..base + 72]
240 .try_into()
241 .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
242 ) as usize;
243
244 if offset + length > raw.len() {
245 bail!(
246 "entry '{}' data range [{}, {}) exceeds file size {}",
247 name,
248 offset,
249 offset + length,
250 raw.len()
251 );
252 }
253
254 let data = raw[offset..offset + length].to_vec();
255 bundle
256 .add(BundleEntry { name, data })
257 .with_context(|| format!("failed to add entry {}", i))?;
258 }
259
260 Ok(bundle)
261}
262
263pub fn validate_bundle(path: &Path) -> Result<usize> {
268 let raw =
269 fs::read(path).with_context(|| format!("cannot read bundle file: {}", path.display()))?;
270
271 if raw.len() < HEADER_SIZE {
272 bail!("bundle file too small");
273 }
274 if &raw[0..4] != OXB_MAGIC.as_ref() {
275 bail!("invalid OXB magic bytes");
276 }
277
278 let entry_count = u32::from_le_bytes(
279 raw[4..8]
280 .try_into()
281 .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
282 ) as usize;
283 let dir_end = HEADER_SIZE + entry_count * DIR_ENTRY_SIZE;
284
285 if raw.len() < dir_end {
286 bail!("directory extends past end of file");
287 }
288
289 for i in 0..entry_count {
290 let base = HEADER_SIZE + i * DIR_ENTRY_SIZE;
291 let offset = u32::from_le_bytes(
292 raw[base + 64..base + 68]
293 .try_into()
294 .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
295 ) as usize;
296 let length = u32::from_le_bytes(
297 raw[base + 68..base + 72]
298 .try_into()
299 .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
300 ) as usize;
301
302 if offset < dir_end {
303 bail!(
304 "entry {} offset {} is inside the header/directory region",
305 i,
306 offset
307 );
308 }
309 if offset.checked_add(length).is_none_or(|end| end > raw.len()) {
310 bail!(
311 "entry {} data range [{}, {}) exceeds file size",
312 i,
313 offset,
314 offset + length
315 );
316 }
317 }
318
319 Ok(entry_count)
320}
321
322pub fn extract_bundle(path: &Path, output_dir: &Path) -> Result<Vec<String>> {
327 let bundle = load_bundle(path)?;
328 fs::create_dir_all(output_dir)
329 .with_context(|| format!("cannot create output directory: {}", output_dir.display()))?;
330
331 let mut names = Vec::new();
332 for entry in &bundle.entries {
333 let out_path = output_dir.join(&entry.name);
334 fs::write(&out_path, &entry.data)
335 .with_context(|| format!("cannot write extracted file: {}", out_path.display()))?;
336 names.push(entry.name.clone());
337 }
338 Ok(names)
339}
340
341pub fn bundle_from_dir(dir: &Path) -> Result<AssetBundle> {
346 let mut bundle = AssetBundle::new();
347 let read_dir =
348 fs::read_dir(dir).with_context(|| format!("cannot read directory: {}", dir.display()))?;
349
350 for result in read_dir {
351 let entry = result.with_context(|| "failed to read directory entry")?;
352 let meta = entry.metadata()?;
353 if !meta.is_file() {
354 continue;
355 }
356 let file_name = entry
357 .file_name()
358 .into_string()
359 .map_err(|_| anyhow!("non-UTF-8 file name in directory"))?;
360 let data = fs::read(entry.path())?;
361 bundle.add_bytes(file_name, data)?;
362 }
363
364 Ok(bundle)
365}
366
367fn validate_name(name: &str) -> Result<()> {
370 if name.is_empty() {
371 bail!("entry name must not be empty");
372 }
373 if name.len() > MAX_ENTRY_NAME {
374 bail!(
375 "entry name '{}' is {} bytes, exceeds MAX_ENTRY_NAME ({})",
376 name,
377 name.len(),
378 MAX_ENTRY_NAME
379 );
380 }
381 if name.contains('\0') {
382 bail!("entry name must not contain null bytes");
383 }
384 Ok(())
385}
386
387#[cfg(test)]
390mod tests {
391 use super::*;
392 use std::path::PathBuf;
393
394 fn tmp_path(filename: &str) -> PathBuf {
395 PathBuf::from(format!("/tmp/{}", filename))
396 }
397
398 #[test]
401 fn test_bundle_entry_new() {
402 let entry = BundleEntry::new("mesh.bin", vec![1, 2, 3]).expect("should succeed");
403 assert_eq!(entry.name, "mesh.bin");
404 assert_eq!(entry.data, vec![1, 2, 3]);
405 assert_eq!(entry.size(), 3);
406 }
407
408 #[test]
409 fn test_bundle_entry_name_too_long() {
410 let long_name = "a".repeat(MAX_ENTRY_NAME + 1);
411 let result = BundleEntry::new(long_name, vec![]);
412 assert!(result.is_err());
413 }
414
415 #[test]
418 fn test_asset_bundle_new() {
419 let bundle = AssetBundle::new();
420 assert_eq!(bundle.entry_count(), 0);
421 assert_eq!(bundle.total_size(), 0);
422 }
423
424 #[test]
425 fn test_add_bytes() {
426 let mut bundle = AssetBundle::new();
427 bundle.add_bytes("alpha", vec![0xFF, 0x00]).expect("should succeed");
428 bundle.add_bytes("beta", vec![1, 2, 3, 4]).expect("should succeed");
429 assert_eq!(bundle.entry_count(), 2);
430 assert_eq!(bundle.total_size(), 6);
431 }
432
433 #[test]
434 fn test_add_str() {
435 let mut bundle = AssetBundle::new();
436 bundle.add_str("readme.txt", "Hello, world!").expect("should succeed");
437 let entry = bundle.get("readme.txt").expect("should succeed");
438 assert_eq!(entry.data, b"Hello, world!");
439 }
440
441 #[test]
442 fn test_contains_and_get() {
443 let mut bundle = AssetBundle::new();
444 bundle.add_bytes("x", vec![42]).expect("should succeed");
445 assert!(bundle.contains("x"));
446 assert!(!bundle.contains("y"));
447 assert_eq!(bundle.get("x").expect("should succeed").data, vec![42]);
448 assert!(bundle.get("y").is_none());
449 }
450
451 #[test]
452 fn test_remove_entry() {
453 let mut bundle = AssetBundle::new();
454 bundle.add_bytes("keep", vec![1]).expect("should succeed");
455 bundle.add_bytes("drop", vec![2]).expect("should succeed");
456 assert!(bundle.remove("drop"));
457 assert!(!bundle.contains("drop"));
458 assert_eq!(bundle.entry_count(), 1);
459 assert!(!bundle.remove("drop")); }
461
462 #[test]
463 fn test_total_size() {
464 let mut bundle = AssetBundle::new();
465 bundle.add_bytes("a", vec![0u8; 100]).expect("should succeed");
466 bundle.add_bytes("b", vec![0u8; 200]).expect("should succeed");
467 assert_eq!(bundle.total_size(), 300);
468 }
469
470 #[test]
473 fn test_export_and_load_roundtrip() {
474 let path = tmp_path("oxihuman_test_roundtrip.oxb");
475
476 let mut bundle = AssetBundle::new();
477 bundle.add_str("hello.txt", "Hello OXB").expect("should succeed");
478 bundle
479 .add_bytes("data.bin", vec![0xDE, 0xAD, 0xBE, 0xEF])
480 .expect("should succeed");
481 bundle.add_bytes("empty.bin", vec![]).expect("should succeed");
482 export_bundle(&bundle, &path).expect("should succeed");
483
484 let loaded = load_bundle(&path).expect("should succeed");
485 assert_eq!(loaded.entry_count(), 3);
486 assert_eq!(loaded.get("hello.txt").expect("should succeed").data, b"Hello OXB");
487 assert_eq!(
488 loaded.get("data.bin").expect("should succeed").data,
489 vec![0xDE, 0xAD, 0xBE, 0xEF]
490 );
491 assert_eq!(loaded.get("empty.bin").expect("should succeed").data, Vec::<u8>::new());
492 }
493
494 #[test]
495 fn test_validate_bundle() {
496 let path = tmp_path("oxihuman_test_validate.oxb");
497
498 let mut bundle = AssetBundle::new();
499 bundle.add_bytes("a", vec![1, 2]).expect("should succeed");
500 bundle.add_bytes("b", vec![3, 4, 5]).expect("should succeed");
501 export_bundle(&bundle, &path).expect("should succeed");
502
503 let count = validate_bundle(&path).expect("should succeed");
504 assert_eq!(count, 2);
505 }
506
507 #[test]
508 fn test_validate_bad_magic() {
509 let path = tmp_path("oxihuman_test_bad_magic.oxb");
510 fs::write(&path, b"NOTOXB1\x00\x00\x00\x00\x00\x00\x00\x00\x00").expect("should succeed");
511 let result = validate_bundle(&path);
512 assert!(result.is_err());
513 let msg = result.unwrap_err().to_string();
514 assert!(msg.contains("magic"));
515 }
516
517 #[test]
518 fn test_extract_bundle() {
519 let bundle_path = tmp_path("oxihuman_test_extract.oxb");
520 let out_dir = PathBuf::from("/tmp/oxihuman_test_extract_out");
521
522 let mut bundle = AssetBundle::new();
523 bundle.add_str("file1.txt", "content one").expect("should succeed");
524 bundle.add_str("file2.txt", "content two").expect("should succeed");
525 export_bundle(&bundle, &bundle_path).expect("should succeed");
526
527 let names = extract_bundle(&bundle_path, &out_dir).expect("should succeed");
528 assert_eq!(names.len(), 2);
529
530 let f1 = fs::read_to_string(out_dir.join("file1.txt")).expect("should succeed");
531 let f2 = fs::read_to_string(out_dir.join("file2.txt")).expect("should succeed");
532 assert_eq!(f1, "content one");
533 assert_eq!(f2, "content two");
534 }
535
536 #[test]
537 fn test_bundle_from_dir() {
538 let dir = PathBuf::from("/tmp/oxihuman_test_bundle_from_dir");
539 fs::create_dir_all(&dir).expect("should succeed");
540 fs::write(dir.join("asset_a.bin"), b"aaa").expect("should succeed");
541 fs::write(dir.join("asset_b.bin"), b"bbbb").expect("should succeed");
542
543 let bundle = bundle_from_dir(&dir).expect("should succeed");
544 assert_eq!(bundle.entry_count(), 2);
545 assert!(bundle.contains("asset_a.bin"));
546 assert!(bundle.contains("asset_b.bin"));
547 assert_eq!(bundle.get("asset_a.bin").expect("should succeed").data, b"aaa");
548 }
549}