Skip to main content

cargo_codesign/ds_store/
mod.rs

1//! Typed `.DS_Store` file format — encode and decode.
2//!
3//! This module generates binary `.DS_Store` files for macOS DMG installers.
4//! It writes the buddy-allocator B-tree format that Finder reads to determine
5//! window size, icon positions, and background images.
6//!
7//! **Note:** The `.DS_Store` format is undocumented by Apple. This implementation
8//! is reverse-engineered from observed Finder output and community research.
9//! Apple may change the format at any time without notice.
10//!
11//! # Usage
12//!
13//! ```rust
14//! use cargo_codesign::ds_store::{DsStoreBuilder, DMG_BG_FILENAME};
15//!
16//! let ds_store = DsStoreBuilder::new("MyApp.app", "MyApp")
17//!     .window_size(660, 400)
18//!     .icon_size(128)
19//!     .app_position(160, 200)
20//!     .apps_link_position(500, 200)
21//!     .build();
22//!
23//! let bytes = ds_store.encode();
24//! // Write `bytes` to `.DS_Store` in the DMG staging directory.
25//! // Copy your background image to `.background/bg.png` in the same directory.
26//! // Then run: hdiutil create -format UDZO -srcfolder <staging> output.dmg
27//! ```
28//!
29//! # Architecture
30//!
31//! The module is split into focused files by binary format:
32//!
33//! - `alias` — macOS Alias V2 (big-endian, 6-byte prefix + 144-byte body + tagged data)
34//! - `bookmark` — macOS Bookmark (little-endian, 64-byte header + data items + TOC)
35//! - `allocator` — Buddy allocator primitives (Bud1 prelude, DSDB, block addresses)
36//! - `encode` — `BinaryEncode` impls for record types (Iloc, bwsp, icvp, pBBk, vSrn)
37//! - `decode` — `BinaryDecode` impls and `DsRecord::decode_one` for parsing
38//! - `types` — Shared type definitions, traits, and error types
39
40mod 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
53/// Canonical background image filename inside the DMG's `.background/` folder.
54pub const DMG_BG_FILENAME: &str = "bg.png";
55
56/// A complete `.DS_Store` file: a set of records that encode to the buddy-allocator B-tree format.
57#[derive(Debug, Clone, PartialEq)]
58pub struct DsStore {
59    pub(crate) records: Vec<DsRecord>,
60}
61
62impl DsStore {
63    /// Decode a `.DS_Store` binary file into a `DsStore`.
64    ///
65    /// The file must start with a 4-byte header (`0x00000001`), followed by
66    /// the Bud1 data region containing the allocator info, DSDB, and leaf node.
67    #[allow(clippy::cast_possible_truncation, dead_code)]
68    pub(crate) fn decode(data: &[u8]) -> Result<Self, DecodeError> {
69        // Minimum: 4-byte file header + 32-byte Bud1 prelude
70        if data.len() < 36 {
71            return Err(DecodeError::TooShort {
72                expected: 36,
73                got: data.len(),
74            });
75        }
76
77        // Verify file header
78        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        // Parse Bud1 prelude (starts at byte 4, the data region)
87        let prelude = Bud1Prelude::decode(&data[4..36])?;
88
89        // Parse allocator info at data_region + info_offset
90        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        // Find DSDB block index from the TOC
100        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        // Compute DSDB block offset: strip size class bits, add 4 for file header
115        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        // Compute leaf node offset from the root_node block address
126        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        // Leaf node: skip pair_count (4 bytes), read record_count (4 bytes)
137        if data.len() < leaf_pos + 8 {
138            return Err(DecodeError::TooShort {
139                expected: leaf_pos + 8,
140                got: data.len(),
141            });
142        }
143        // pair_count at leaf_pos..leaf_pos+4 (0 for a leaf — we skip it)
144        let record_count =
145            u32::from_be_bytes(data[leaf_pos + 4..leaf_pos + 8].try_into().unwrap()) as usize;
146
147        // Decode records sequentially
148        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    /// Encode the `DsStore` into a complete `.DS_Store` binary file.
160    ///
161    /// The layout matches the buddy-allocator B-tree format that Finder expects:
162    /// one DSDB block, one leaf node, and an allocator info block.
163    #[allow(clippy::cast_possible_truncation)]
164    pub fn encode(&self) -> Vec<u8> {
165        let num_records = self.records.len() as u32;
166
167        // --- Serialize the two data blocks ---
168        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        // --- Compute block sizes (power-of-two, minimum 32) ---
176        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        // --- Layout the data region ---
183        //
184        // Data region starts at file offset 4 (right after the 4-byte file header).
185        // All offsets below are relative to byte 4.
186        //
187        //   data_offset 0..32           = Bud1 prelude (not a block)
188        //   data_offset 32..32+dsdb     = DSDB block       -> block[1]
189        //   data_offset ...             = leaf node block   -> block[2]
190        //   data_offset ...             = allocator info    -> block[0]
191        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        // Re-serialize DSDB with correct root_node = 2 (leaf is block index 2)
199        let dsdb_data = Dsdb {
200            root_node: 2,
201            num_records,
202        }
203        .encode();
204
205        // Allocator info needs to know its own address for block[0].
206        // We compute info_alloc first, then build the block.
207        let info_alloc = next_power_of_two(1200); // allocator info is ~1169 bytes; always 2048
208        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        // Verify our size estimate was correct
218        debug_assert!(
219            info_block.len() <= info_alloc,
220            "allocator info exceeds estimated alloc"
221        );
222
223        // --- Assemble the file ---
224        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 header
228        file.extend_from_slice(&[0, 0, 0, 1]);
229
230        // Prelude (32 bytes in the data region)
231        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        // DSDB block (padded to dsdb_alloc)
239        file.extend_from_slice(&dsdb_data);
240        file.resize(file.len() + (dsdb_alloc - dsdb_data.len()), 0);
241
242        // Leaf node block (padded to leaf_alloc)
243        file.extend_from_slice(&leaf_data);
244        file.resize(file.len() + (leaf_alloc - leaf_data.len()), 0);
245
246        // Allocator info block (padded to info_alloc)
247        file.extend_from_slice(&info_block);
248        file.resize(file.len() + (info_alloc - info_block.len()), 0);
249
250        file
251    }
252}
253
254/// Serialize a B-tree leaf node containing the given records.
255///
256/// Leaf node layout:
257///   - `pair_count`: 0 (u32 BE) -- signals this is a leaf
258///   - `record_count` (u32 BE)
259///   - serialized records
260#[allow(clippy::cast_possible_truncation)]
261fn serialize_leaf_node(records: &[DsRecord]) -> Vec<u8> {
262    let mut node = Vec::new();
263    // pair_count = 0 -> leaf
264    node.extend_from_slice(&0u32.to_be_bytes());
265    // record count (always small; truncation safe)
266    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
273/// Builder for constructing a `DsStore` for a DMG installer layout.
274pub 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    /// Build the `DsStore`. The background filename is always [`DMG_BG_FILENAME`].
323    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            // bwsp: window settings for volume root "."
365            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            // icvp: icon view settings for volume root "."
379            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            // pBBk: bookmark for volume root "."
398            DsRecord {
399                filename: ".".to_string(),
400                value: RecordValue::PBBk(bookmark),
401            },
402            // vSrn(1) for volume root "."
403            DsRecord {
404                filename: ".".to_string(),
405                value: RecordValue::VSrn(1),
406            },
407            // Iloc: Applications symlink position
408            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            // Iloc: app icon position
416            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        // Sort by (filename as UTF-16 code units, then record code bytes).
426        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    // --- Decode tests ---
470
471    #[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        // Wrong header (should be 0x00000001)
515        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}