Skip to main content

fs_mac/
mac_alias.rs

1//! Classic Mac alias resolution.
2//!
3//! Parses the `alis` resource record stored in a file's resource fork
4//! (the marker for aliases is `kIsAlias`/`0x8000` in `FInfo.fdFlags`).
5//!
6//! We extract just enough to show the user where an alias points:
7//!   - Target filename (from the fixed portion at offset 50)
8//!   - Volume name   (from the fixed portion at offset 10)
9//!   - Optional absolute path from tagged record 18 (UTF-8) or 2 (MacRoman)
10//!
11//! We do not attempt to follow the alias; the target may no longer exist.
12
13use byteorder::{BigEndian, ByteOrder};
14#[cfg(feature = "rust173-polyfill")]
15use disk_core::rust173::IntIsMultipleOf as _;
16
17/// Finder flag bit indicating the file is an alias.
18pub const IS_ALIAS_FLAG: u16 = 0x8000;
19
20/// Type code for classic HFS+ UNIX symlinks.
21pub const SLNK_TYPE: &str = "slnk";
22/// Creator code for classic HFS+ UNIX symlinks.
23pub const RHAP_CREATOR: &str = "rhap";
24
25/// Read a Pascal string padded to `field_len` bytes. The first byte is the
26/// length; `field_len - 1` bytes of data follow. Non-ASCII bytes are passed
27/// through (MacRoman ≈ ASCII for the low 128), with high bytes mapped to `?`.
28fn read_pascal_string(field: &[u8]) -> String {
29    if field.is_empty() {
30        return String::new();
31    }
32    let len = field[0] as usize;
33    let max = field.len().saturating_sub(1).min(len);
34    let mut out = String::with_capacity(max);
35    for &b in &field[1..1 + max] {
36        if b < 0x80 {
37            out.push(b as char);
38        } else {
39            out.push('?');
40        }
41    }
42    out
43}
44
45/// Parse an `alis` resource record and return a human-readable target string
46/// like `"Volume:TargetName"`, or an absolute path when one is present.
47///
48/// Returns `None` if the record is too short or clearly malformed.
49pub fn parse_alis_record(data: &[u8]) -> Option<String> {
50    // Fixed portion is 150 bytes. Older records may be shorter but always ≥ 114
51    // (up through the target filename field).
52    if data.len() < 114 {
53        return None;
54    }
55
56    // Volume name: Pascal string in bytes 10..38 (28-byte field).
57    let volume = read_pascal_string(&data[10..38]);
58    // Target file/folder name: Pascal string in bytes 50..114 (64-byte field).
59    let target = read_pascal_string(&data[50..114]);
60
61    // Scan tagged records for a UTF-8 (tag 18) or Pascal (tag 2) absolute path.
62    let abs_path = if data.len() > 150 {
63        scan_tagged_path(&data[150..])
64    } else {
65        None
66    };
67
68    match (abs_path, volume.is_empty(), target.is_empty()) {
69        (Some(p), _, _) if !p.is_empty() => Some(p),
70        (_, true, true) => None,
71        (_, true, false) => Some(target),
72        (_, false, true) => Some(format!("{volume}:")),
73        (_, false, false) => Some(format!("{volume}:{target}")),
74    }
75}
76
77/// Walk the tag-length-value records after the fixed portion looking for an
78/// absolute path.  Each record is: u16 tag, u16 length, `length` data bytes,
79/// padded to even length.  Tag `0xFFFF` terminates.
80fn scan_tagged_path(tags: &[u8]) -> Option<String> {
81    let mut i = 0;
82    while i + 4 <= tags.len() {
83        let tag = BigEndian::read_u16(&tags[i..i + 2]);
84        let len = BigEndian::read_u16(&tags[i + 2..i + 4]) as usize;
85        if tag == 0xFFFF {
86            return None;
87        }
88        let data_start = i + 4;
89        let data_end = data_start.checked_add(len)?;
90        if data_end > tags.len() {
91            return None;
92        }
93        match tag {
94            // Tag 18 — absolute path as UTF-8.
95            18 => {
96                if let Ok(s) = std::str::from_utf8(&tags[data_start..data_end]) {
97                    let trimmed = s.trim_end_matches('\0');
98                    if !trimmed.is_empty() {
99                        return Some(trimmed.to_string());
100                    }
101                }
102            }
103            // Tag 2 — absolute path (Pascal-ish, MacRoman). Not length-prefixed
104            // here; the record length IS the string length.
105            2 => {
106                let mut out = String::with_capacity(len);
107                for &b in &tags[data_start..data_end] {
108                    if b == 0 {
109                        break;
110                    } else if b < 0x80 {
111                        out.push(b as char);
112                    } else {
113                        out.push('?');
114                    }
115                }
116                if !out.is_empty() {
117                    return Some(out);
118                }
119            }
120            _ => {}
121        }
122        // Advance past data, rounding length up to even.
123        let padded = (len + 1) & !1;
124        i = data_start + padded;
125    }
126    None
127}
128
129/// Parse a resource fork and return the data bytes of the first `alis`
130/// resource, if any.
131///
132/// Classic Mac resource fork layout:
133/// - 16-byte header: data_offset(u32), map_offset(u32), data_len(u32), map_len(u32)
134/// - At `map_offset`: 24 reserved bytes, then fork attrs (u16), type-list offset
135///   (u16, from start of map), name-list offset (u16).
136/// - At (map_offset + type_list_offset): `num_types - 1` (u16), followed by
137///   an array of type entries: four-cc (4), `count-1` (u16), ref-list offset
138///   (u16, from start of type list).
139/// - Ref list entry (12 bytes): id (i16), name_off (i16), attrs+data_off (4,
140///   the top byte is attrs and bottom 3 bytes are offset from data_offset),
141///   reserved (4).
142/// - At data_offset + data_off: u32 length followed by `length` data bytes.
143pub fn find_alis_in_resource_fork(rsrc: &[u8]) -> Option<Vec<u8>> {
144    if rsrc.len() < 16 {
145        return None;
146    }
147    let data_off = BigEndian::read_u32(&rsrc[0..4]) as usize;
148    let map_off = BigEndian::read_u32(&rsrc[4..8]) as usize;
149    let _data_len = BigEndian::read_u32(&rsrc[8..12]) as usize;
150    let map_len = BigEndian::read_u32(&rsrc[12..16]) as usize;
151
152    if map_off + map_len > rsrc.len() || map_off + 30 > rsrc.len() {
153        return None;
154    }
155    let map = &rsrc[map_off..map_off + map_len.min(rsrc.len() - map_off)];
156    if map.len() < 30 {
157        return None;
158    }
159
160    // Map header (28 bytes): copy of rsrc header (16) + handle (4) + file-ref (2)
161    // + fork attrs (2) + type-list off (2) + name-list off (2).
162    let type_list_off = BigEndian::read_u16(&map[24..26]) as usize;
163    if type_list_off + 2 > map.len() {
164        return None;
165    }
166
167    let num_types = BigEndian::read_u16(&map[type_list_off..type_list_off + 2]).wrapping_add(1);
168    let types_start = type_list_off + 2;
169
170    for t in 0..num_types as usize {
171        let te = types_start + t * 8;
172        if te + 8 > map.len() {
173            return None;
174        }
175        let four_cc = &map[te..te + 4];
176        let count = BigEndian::read_u16(&map[te + 4..te + 6]).wrapping_add(1);
177        let ref_list_off = BigEndian::read_u16(&map[te + 6..te + 8]) as usize;
178
179        if four_cc == b"alis" && count > 0 {
180            // Take the first resource in the ref list.
181            let re = type_list_off + ref_list_off;
182            if re + 12 > map.len() {
183                return None;
184            }
185            // Data offset is a 24-bit value at bytes 5..8 (byte 4 is attrs).
186            let data_rel = ((map[re + 5] as usize) << 16)
187                | ((map[re + 6] as usize) << 8)
188                | (map[re + 7] as usize);
189            let abs = data_off + data_rel;
190            if abs + 4 > rsrc.len() {
191                return None;
192            }
193            let res_len = BigEndian::read_u32(&rsrc[abs..abs + 4]) as usize;
194            let res_start = abs + 4;
195            if res_start + res_len > rsrc.len() {
196                return None;
197            }
198            return Some(rsrc[res_start..res_start + res_len].to_vec());
199        }
200    }
201
202    None
203}
204
205/// One-shot helper: given raw resource-fork bytes, return a display string
206/// for the alias target, or `None` if no `alis` resource is present / parseable.
207pub fn resolve_alias_target(rsrc: &[u8]) -> Option<String> {
208    let alis = find_alis_in_resource_fork(rsrc)?;
209    parse_alis_record(&alis)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn build_pascal_field(s: &str, field_len: usize) -> Vec<u8> {
217        let mut out = vec![0u8; field_len];
218        let bytes = s.as_bytes();
219        let n = bytes.len().min(field_len - 1).min(u8::MAX as usize);
220        out[0] = n as u8;
221        out[1..1 + n].copy_from_slice(&bytes[..n]);
222        out
223    }
224
225    fn build_alis_record(volume: &str, target: &str, extra_tags: &[u8]) -> Vec<u8> {
226        let mut rec = vec![0u8; 150];
227        // volume field: 10..38 (28 bytes)
228        rec[10..38].copy_from_slice(&build_pascal_field(volume, 28));
229        // target field: 50..114 (64 bytes)
230        rec[50..114].copy_from_slice(&build_pascal_field(target, 64));
231        rec.extend_from_slice(extra_tags);
232        rec
233    }
234
235    #[test]
236    fn parse_alis_minimal_volume_and_target() {
237        let rec = build_alis_record("MyDisk", "ReadMe", &[]);
238        assert_eq!(parse_alis_record(&rec).as_deref(), Some("MyDisk:ReadMe"));
239    }
240
241    #[test]
242    fn parse_alis_tag_18_utf8_path_wins() {
243        let mut tags = Vec::new();
244        tags.extend_from_slice(&18u16.to_be_bytes()); // tag
245        let path = b"/Volumes/MyDisk/Apps/MyApp.app";
246        tags.extend_from_slice(&(path.len() as u16).to_be_bytes());
247        tags.extend_from_slice(path);
248        if !path.len().is_multiple_of(2) {
249            tags.push(0);
250        }
251        tags.extend_from_slice(&0xFFFFu16.to_be_bytes());
252        tags.extend_from_slice(&0u16.to_be_bytes());
253
254        let rec = build_alis_record("MyDisk", "MyApp.app", &tags);
255        assert_eq!(
256            parse_alis_record(&rec).as_deref(),
257            Some("/Volumes/MyDisk/Apps/MyApp.app")
258        );
259    }
260
261    #[test]
262    fn parse_alis_rejects_short() {
263        assert!(parse_alis_record(&[0u8; 50]).is_none());
264    }
265
266    #[test]
267    fn parse_alis_empty_volume_and_target() {
268        let rec = build_alis_record("", "", &[]);
269        assert!(parse_alis_record(&rec).is_none());
270    }
271
272    #[test]
273    fn find_alis_in_minimal_rsrc() {
274        // Build a tiny resource fork with one `alis` resource.
275        // Layout:
276        //   [0..16]   header: data_off, map_off, data_len, map_len
277        //   [data_off..] length-prefixed resource data
278        //   [map_off..] map (30 bytes reserved + our layout)
279
280        let alis_body = b"hello-alias";
281        let res_data_start = 16;
282        let res_len_prefix = 4;
283        let data_section_len = res_len_prefix + alis_body.len();
284
285        let mut buf = vec![0u8; 16];
286        // Placeholder for res length + data
287        buf.extend_from_slice(&(alis_body.len() as u32).to_be_bytes());
288        buf.extend_from_slice(alis_body);
289
290        let map_off = buf.len();
291
292        // Map layout (28 bytes fixed):
293        //   [0..16]  copy of rsrc header
294        //   [16..20] handle to next map (reserved)
295        //   [20..22] file reference number (reserved)
296        //   [22..24] fork attrs
297        //   [24..26] offset from map beginning to type list
298        //   [26..28] offset from map beginning to name list
299        let map_header_size = 28;
300        let type_list_off_in_map = map_header_size;
301        // Ref list immediately after the single type entry.
302        let ref_list_off_from_type_list = 2 + 8; // skip num-1 and one type entry
303
304        let mut map = vec![0u8; map_header_size];
305        map[24] = (type_list_off_in_map >> 8) as u8;
306        map[25] = (type_list_off_in_map & 0xFF) as u8;
307        // name list offset: point past everything (unused)
308        map[26] = 0xFF;
309        map[27] = 0xFF;
310        // num_types - 1 = 0
311        map.extend_from_slice(&[0, 0]);
312        // Type entry: "alis", count-1=0, ref_list_off
313        map.extend_from_slice(b"alis");
314        map.extend_from_slice(&[0, 0]); // count-1
315        map.extend_from_slice(&(ref_list_off_from_type_list as u16).to_be_bytes());
316        // Ref list entry (12 bytes): id(2), name_off(2), attrs+data_off(4), reserved(4)
317        map.extend_from_slice(&[0, 0]); // id 0
318        map.extend_from_slice(&[0xFF, 0xFF]); // name off = none
319        let data_rel: u32 = 0; // resource is first in data section
320        map.push(0); // attrs byte
321        map.push(((data_rel >> 16) & 0xFF) as u8);
322        map.push(((data_rel >> 8) & 0xFF) as u8);
323        map.push((data_rel & 0xFF) as u8);
324        map.extend_from_slice(&[0, 0, 0, 0]); // reserved
325
326        let map_len = map.len();
327
328        // Now fix header
329        BigEndian::write_u32(&mut buf[0..4], res_data_start as u32);
330        BigEndian::write_u32(&mut buf[4..8], map_off as u32);
331        BigEndian::write_u32(&mut buf[8..12], data_section_len as u32);
332        BigEndian::write_u32(&mut buf[12..16], map_len as u32);
333
334        buf.extend_from_slice(&map);
335
336        let found = find_alis_in_resource_fork(&buf).expect("alis should be found");
337        assert_eq!(found, alis_body);
338    }
339}