1mod alias;
41mod allocator;
42mod bookmark;
43mod decode;
44mod encode;
45mod types;
46
47pub(crate) use types::*;
48
49use alias::{AliasKind, AliasTag, AliasV2};
50use allocator::{block_address, log2, next_power_of_two, AllocatorInfo, Bud1Prelude, Dsdb};
51use bookmark::Bookmark;
52
53pub const DMG_BG_FILENAME: &str = "bg.png";
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct DsStore {
59 pub(crate) records: Vec<DsRecord>,
60}
61
62impl DsStore {
63 #[allow(clippy::cast_possible_truncation, dead_code)]
68 pub(crate) fn decode(data: &[u8]) -> Result<Self, DecodeError> {
69 if data.len() < 36 {
71 return Err(DecodeError::TooShort {
72 expected: 36,
73 got: data.len(),
74 });
75 }
76
77 let header = u32::from_be_bytes(data[0..4].try_into().unwrap());
79 if header != 1 {
80 return Err(DecodeError::InvalidMagic {
81 expected: b"\x00\x00\x00\x01",
82 got: data[0..4].to_vec(),
83 });
84 }
85
86 let prelude = Bud1Prelude::decode(&data[4..36])?;
88
89 let info_pos = 4 + prelude.info_offset as usize;
91 if info_pos >= data.len() {
92 return Err(DecodeError::TooShort {
93 expected: info_pos + 1,
94 got: data.len(),
95 });
96 }
97 let alloc_info = AllocatorInfo::decode(&data[info_pos..])?;
98
99 let dsdb_block_idx = alloc_info
101 .toc
102 .iter()
103 .find(|(name, _)| name == "DSDB")
104 .map(|(_, idx)| *idx as usize)
105 .ok_or_else(|| DecodeError::Other("no DSDB entry in TOC".into()))?;
106
107 if dsdb_block_idx >= alloc_info.block_addresses.len() {
108 return Err(DecodeError::Other(format!(
109 "DSDB block index {dsdb_block_idx} out of range (have {} blocks)",
110 alloc_info.block_addresses.len()
111 )));
112 }
113
114 let dsdb_addr = alloc_info.block_addresses[dsdb_block_idx];
116 let dsdb_pos = (dsdb_addr & !0x1f) as usize + 4;
117 if dsdb_pos >= data.len() {
118 return Err(DecodeError::TooShort {
119 expected: dsdb_pos + 20,
120 got: data.len(),
121 });
122 }
123 let dsdb = Dsdb::decode(&data[dsdb_pos..])?;
124
125 let root_node = dsdb.root_node as usize;
127 if root_node >= alloc_info.block_addresses.len() {
128 return Err(DecodeError::Other(format!(
129 "root_node block index {root_node} out of range (have {} blocks)",
130 alloc_info.block_addresses.len()
131 )));
132 }
133 let leaf_addr = alloc_info.block_addresses[root_node];
134 let leaf_pos = (leaf_addr & !0x1f) as usize + 4;
135
136 if data.len() < leaf_pos + 8 {
138 return Err(DecodeError::TooShort {
139 expected: leaf_pos + 8,
140 got: data.len(),
141 });
142 }
143 let record_count =
145 u32::from_be_bytes(data[leaf_pos + 4..leaf_pos + 8].try_into().unwrap()) as usize;
146
147 let mut pos = leaf_pos + 8;
149 let mut records = Vec::with_capacity(record_count);
150 for _ in 0..record_count {
151 let (record, consumed) = DsRecord::decode_one(&data[pos..])?;
152 records.push(record);
153 pos += consumed;
154 }
155
156 Ok(DsStore { records })
157 }
158
159 #[allow(clippy::cast_possible_truncation)]
164 pub fn encode(&self) -> Vec<u8> {
165 let num_records = self.records.len() as u32;
166
167 let leaf_data = serialize_leaf_node(&self.records);
169 let dsdb_placeholder = Dsdb {
170 root_node: 0,
171 num_records,
172 };
173 let dsdb_data_placeholder = dsdb_placeholder.encode();
174
175 let dsdb_alloc = next_power_of_two(dsdb_data_placeholder.len());
177 let leaf_alloc = next_power_of_two(leaf_data.len());
178
179 let dsdb_log2 = log2(dsdb_alloc);
180 let leaf_log2 = log2(leaf_alloc);
181
182 let dsdb_offset: u32 = 32;
192 let leaf_offset: u32 = dsdb_offset + dsdb_alloc as u32;
193 let info_offset: u32 = leaf_offset + leaf_alloc as u32;
194
195 let dsdb_addr = block_address(dsdb_offset, dsdb_log2);
196 let leaf_addr = block_address(leaf_offset, leaf_log2);
197
198 let dsdb_data = Dsdb {
200 root_node: 2,
201 num_records,
202 }
203 .encode();
204
205 let info_alloc = next_power_of_two(1200); let info_log2 = log2(info_alloc);
209 let info_addr = block_address(info_offset, info_log2);
210
211 let allocator_info = AllocatorInfo {
212 block_addresses: vec![info_addr, dsdb_addr, leaf_addr],
213 toc: vec![("DSDB".to_string(), 1)],
214 };
215 let info_block = allocator_info.encode();
216
217 debug_assert!(
219 info_block.len() <= info_alloc,
220 "allocator info exceeds estimated alloc"
221 );
222
223 let total_data_size = 32 + dsdb_alloc + leaf_alloc + info_alloc;
225 let mut file = Vec::with_capacity(4 + total_data_size);
226
227 file.extend_from_slice(&[0, 0, 0, 1]);
229
230 let prelude = Bud1Prelude {
232 info_offset,
233 info_alloc: info_alloc as u32,
234 leaf_addr,
235 };
236 file.extend_from_slice(&prelude.encode());
237
238 file.extend_from_slice(&dsdb_data);
240 file.resize(file.len() + (dsdb_alloc - dsdb_data.len()), 0);
241
242 file.extend_from_slice(&leaf_data);
244 file.resize(file.len() + (leaf_alloc - leaf_data.len()), 0);
245
246 file.extend_from_slice(&info_block);
248 file.resize(file.len() + (info_alloc - info_block.len()), 0);
249
250 file
251 }
252}
253
254#[allow(clippy::cast_possible_truncation)]
261fn serialize_leaf_node(records: &[DsRecord]) -> Vec<u8> {
262 let mut node = Vec::new();
263 node.extend_from_slice(&0u32.to_be_bytes());
265 node.extend_from_slice(&(records.len() as u32).to_be_bytes());
267 for rec in records {
268 node.extend_from_slice(&rec.encode());
269 }
270 node
271}
272
273pub struct DsStoreBuilder {
275 window_width: u32,
276 window_height: u32,
277 icon_size: u32,
278 app_name: String,
279 app_position: (u32, u32),
280 apps_link_position: (u32, u32),
281 volume_name: String,
282}
283
284impl DsStoreBuilder {
285 pub fn new(app_name: impl Into<String>, volume_name: impl Into<String>) -> Self {
286 Self {
287 window_width: 660,
288 window_height: 400,
289 icon_size: 128,
290 app_name: app_name.into(),
291 app_position: (160, 200),
292 apps_link_position: (500, 200),
293 volume_name: volume_name.into(),
294 }
295 }
296
297 #[must_use]
298 pub fn window_size(mut self, width: u32, height: u32) -> Self {
299 self.window_width = width;
300 self.window_height = height;
301 self
302 }
303
304 #[must_use]
305 pub fn icon_size(mut self, size: u32) -> Self {
306 self.icon_size = size;
307 self
308 }
309
310 #[must_use]
311 pub fn app_position(mut self, x: u32, y: u32) -> Self {
312 self.app_position = (x, y);
313 self
314 }
315
316 #[must_use]
317 pub fn apps_link_position(mut self, x: u32, y: u32) -> Self {
318 self.apps_link_position = (x, y);
319 self
320 }
321
322 pub fn build(self) -> DsStore {
324 let alias = AliasV2 {
325 kind: AliasKind::File,
326 volume_name: self.volume_name.clone(),
327 volume_created: 0,
328 volume_signature: *b"H+",
329 volume_type: 5,
330 parent_dir_id: 0,
331 filename: DMG_BG_FILENAME.to_string(),
332 file_number: 0,
333 file_created: 0,
334 file_type: [0; 4],
335 file_creator: [0; 4],
336 nlvl_from: 0xFFFF,
337 nlvl_to: 0xFFFF,
338 vol_attrs: 0,
339 vol_fs_id: 0,
340 tags: vec![
341 AliasTag::ParentDirName(".background".to_string()),
342 AliasTag::UnicodeFilename(DMG_BG_FILENAME.to_string()),
343 AliasTag::UnicodeVolumeName(self.volume_name.clone()),
344 AliasTag::PosixPath(format!("/.background/{DMG_BG_FILENAME}")),
345 AliasTag::VolumeMountPoint(format!("/Volumes/{}", self.volume_name)),
346 ],
347 };
348
349 let bookmark = Bookmark {
350 path_components: vec![
351 "Volumes".to_string(),
352 self.volume_name.clone(),
353 ".background".to_string(),
354 DMG_BG_FILENAME.to_string(),
355 ],
356 volume_name: self.volume_name.clone(),
357 volume_path: format!("/Volumes/{}", self.volume_name),
358 volume_url: format!("file:///Volumes/{}/", self.volume_name),
359 volume_uuid: "00000000-0000-0000-0000-000000000000".to_string(),
360 volume_capacity: 52_428_800,
361 };
362
363 let mut records = vec![
364 DsRecord {
366 filename: ".".to_string(),
367 value: RecordValue::Bwsp(WindowSettings {
368 window_origin: (200, 120),
369 window_width: self.window_width,
370 window_height: self.window_height,
371 show_sidebar: false,
372 container_show_sidebar: false,
373 show_toolbar: false,
374 show_tab_view: false,
375 show_status_bar: false,
376 }),
377 },
378 DsRecord {
380 filename: ".".to_string(),
381 value: RecordValue::Icvp(IconViewSettings {
382 icon_size: self.icon_size,
383 text_size: 12.0,
384 label_on_bottom: true,
385 show_icon_preview: true,
386 show_item_info: false,
387 arrange_by: "none".to_string(),
388 grid_spacing: 100.0,
389 grid_offset_x: 0.0,
390 grid_offset_y: 0.0,
391 view_options_version: 1,
392 background_type: 2,
393 background_color: (1.0, 1.0, 1.0),
394 background_alias: alias,
395 }),
396 },
397 DsRecord {
399 filename: ".".to_string(),
400 value: RecordValue::PBBk(bookmark),
401 },
402 DsRecord {
404 filename: ".".to_string(),
405 value: RecordValue::VSrn(1),
406 },
407 DsRecord {
409 filename: "Applications".to_string(),
410 value: RecordValue::Iloc(IconLocation {
411 x: self.apps_link_position.0,
412 y: self.apps_link_position.1,
413 }),
414 },
415 DsRecord {
417 filename: self.app_name.clone(),
418 value: RecordValue::Iloc(IconLocation {
419 x: self.app_position.0,
420 y: self.app_position.1,
421 }),
422 },
423 ];
424
425 records.sort_by(|a, b| {
427 let a_utf16: Vec<u16> = a.filename.encode_utf16().collect();
428 let b_utf16: Vec<u16> = b.filename.encode_utf16().collect();
429 a_utf16
430 .cmp(&b_utf16)
431 .then_with(|| a.value.record_code().cmp(&b.value.record_code()))
432 });
433
434 DsStore { records }
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn test_ds_store() -> DsStore {
443 DsStoreBuilder::new("JPEG Locker.app", "JPEG Locker")
444 .window_size(660, 400)
445 .icon_size(128)
446 .app_position(160, 200)
447 .apps_link_position(500, 200)
448 .build()
449 }
450
451 #[test]
452 fn output_starts_with_header_and_magic() {
453 let bytes = test_ds_store().encode();
454 assert_eq!(u32::from_be_bytes(bytes[0..4].try_into().unwrap()), 1);
455 assert_eq!(&bytes[4..8], b"Bud1");
456 }
457
458 #[test]
459 fn output_contains_record_codes() {
460 let bytes = test_ds_store().encode();
461 let has_pattern = |pat: &[u8]| bytes.windows(pat.len()).any(|w| w == pat);
462 assert!(has_pattern(b"Iloc"));
463 assert!(has_pattern(b"bwsp"));
464 assert!(has_pattern(b"icvp"));
465 assert!(has_pattern(b"vSrn"));
466 assert!(has_pattern(b"pBBk"));
467 }
468
469 #[test]
472 fn full_roundtrip_encode_decode() {
473 let ds = test_ds_store();
474 let bytes = ds.encode();
475 let decoded = DsStore::decode(&bytes).unwrap();
476 assert_eq!(decoded.records.len(), 6);
477 }
478
479 #[test]
480 fn decode_reference_fixture() {
481 let reference = include_bytes!("../../tests/fixtures/reference.DS_Store");
482 let ds = DsStore::decode(reference).unwrap();
483 let codes: Vec<[u8; 4]> = ds.records.iter().map(|r| r.value.record_code()).collect();
484 assert!(codes.contains(b"bwsp"));
485 assert!(codes.contains(b"icvp"));
486 assert!(codes.contains(b"vSrn"));
487 assert!(codes.contains(b"Iloc"));
488 }
489
490 #[test]
491 fn compare_iloc_positions_with_reference() {
492 let reference = include_bytes!("../../tests/fixtures/reference.DS_Store");
493 let ds = DsStore::decode(reference).unwrap();
494 for rec in &ds.records {
495 if let RecordValue::Iloc(iloc) = &rec.value {
496 if rec.filename == "Applications" {
497 assert_eq!((iloc.x, iloc.y), (500, 200));
498 } else if rec.filename.contains("JPEG Locker") {
499 assert_eq!((iloc.x, iloc.y), (160, 200));
500 }
501 }
502 }
503 }
504
505 #[test]
506 fn decode_rejects_short_data() {
507 let result = DsStore::decode(&[0u8; 10]);
508 assert!(result.is_err());
509 }
510
511 #[test]
512 fn decode_rejects_bad_file_header() {
513 let mut data = vec![0u8; 100];
514 data[0..4].copy_from_slice(&[0, 0, 0, 2]);
516 data[4..8].copy_from_slice(b"Bud1");
517 let result = DsStore::decode(&data);
518 assert!(result.is_err());
519 }
520
521 #[test]
522 fn decode_rejects_bad_bud1_magic() {
523 let mut data = vec![0u8; 100];
524 data[0..4].copy_from_slice(&[0, 0, 0, 1]);
525 data[4..8].copy_from_slice(b"Nope");
526 let result = DsStore::decode(&data);
527 assert!(result.is_err());
528 }
529}