File Coverage

lib/Mac/Alias.pm
Criterion Covered Total %
statement 140 174 80.4
branch 62 96 64.5
condition 16 47 34.0
subroutine 13 13 100.0
pod 6 6 100.0
total 237 336 70.5


line stmt bran cond sub pod time code
1 4     4   250659 use v5.26;
  4         24  
2 4     4   17 use warnings;
  4         5  
  4         162  
3              
4             package Mac::Alias;
5             # ABSTRACT: Read or create macOS alias files
6             $Mac::Alias::VERSION = '1.01';
7              
8 4     4   17 use Carp qw(carp croak);
  4         7  
  4         209  
9 4     4   19 use Fcntl ':seek';
  4         5  
  4         430  
10 4     4   2773 use Path::Tiny;
  4         46691  
  4         225  
11              
12 4     4   34 use Exporter 'import';
  4         7  
  4         7727  
13             our @EXPORT_OK = qw(
14             is_alias
15             make_alias
16             parse_alias
17             read_alias
18             read_alias_mac
19             read_alias_perl
20             );
21             our %EXPORT_TAGS = (all => \@EXPORT_OK);
22              
23              
24             # Finder alias constants
25              
26             our $MAGIC = "book\0\0\0\0mark\0\0\0\0";
27              
28             our %ITEM_TYPES= (
29             0x1004 => 'pathComponents', # POSIX path
30             0x1005 => 'fileIDs', # inode path
31             0x1010 => 'resourceProps',
32             0x1020 => 'fileName',
33             0x1040 => 'creationDate',
34             0x1054 => 'relativeDirsUp',
35             0x1055 => 'relativeDirsDown',
36             0x1056 => 'createdWithRelativeURL',
37             0x2000 => 'volInfoDepths',
38             0x2002 => 'volPath',
39             0x2005 => 'volURL',
40             0x2010 => 'volName',
41             0x2011 => 'volUUID',
42             0x2012 => 'volCapacity',
43             0x2013 => 'volCreationDate',
44             0x2020 => 'volProps',
45             0x2030 => 'volWasBoot',
46             0x2050 => 'volMountURL',
47             0xc001 => 'volHomeDirRelativePathComponentCount',
48             0xc011 => 'userName',
49             0xc012 => 'userUID',
50             0xd001 => 'wasFileIDFormat',
51             0xd010 => 'creationOptions',
52             0xf017 => 'displayName',
53             0xf020 => 'effectiveIconData',
54             0xf022 => 'typeBindingData',
55             0xfe00 => 'aliasData', # 'alis' resource
56             );
57              
58             our %CREATION_OPTIONS = (
59             kCFURLBookmarkCreationMinimalBookmarkMask => 1 << 9,
60             kCFURLBookmarkCreationPreferFileIDResolutionMask => 1 << 8,
61             kCFURLBookmarkCreationSecurityScopeAllowOnlyReadAccess => 1 << 12,
62             kCFURLBookmarkCreationSuitableForBookmarkFile => 1 << 10,
63             kCFURLBookmarkCreationWithSecurityScope => 1 << 11,
64             kCFURLBookmarkCreationWithoutImplicitSecurityScope => 1 << 29,
65             );
66              
67             our $EPOCH_OFFSET = 3600 * 24 * (365 * 31 + 8); # kCFAbsoluteTimeIntervalSince1970
68              
69              
70             sub parse_alias :prototype($) {
71 5     5 1 166 my ($file) = @_;
72            
73 5 50       214 open my $fh, '<:raw', $file or croak "$file: $!";
74 5         105 my $success = read $fh, my $header, 20;
75 5 100 100     66 $success and $MAGIC eq substr $header, 0, 16
76             or croak "$file: Not a data fork alias";
77            
78 3 50       13 my $start = unpack 'V', substr $header, -4
79             or croak "$file: Not a data fork alias: Header empty";
80            
81 3         30 seek $fh, 0, SEEK_SET;
82 3 50 0     25 read $fh, $header, $start or die "$!" || "Unexpected EOF in alias header";
83            
84 3 50 0     17 read $fh, my $next, 4 or die "$!" || "Unexpected EOF in alias data";
85 3         8 $next = unpack 'V', $next;
86            
87 3         6 my @data;
88             my @toc;
89 3   100     13 while ((my $item = pop @toc) || $next) {
90            
91 75         77 my $data = $data[$#data];
92            
93 75         61 my ($item_type, $item_offset, $item_flags, $item_ref);
94            
95 75 100       104 if (ref $item) { # path element
    100          
96 16         23 ($item_offset, $item_ref) = @$item;
97             }
98             elsif (defined $item) {
99 55         85 ($item_type, $item_offset, $item_flags) = unpack 'V V V', $item;
100             }
101             else { # no item left in queue: read next TOC
102 4         3 $item_offset = $next;
103             }
104            
105 75         598 seek $fh, $start + $item_offset, SEEK_SET;
106 75 50 0     489 read $fh, my $chunk_header, 8 or die "$!" || "Unexpected EOF in alias chunk";
107 75         181 my ($chunk_size, $chunk_type) = unpack 'V l<', $chunk_header;
108 75         111 my $bytes = (read $fh, my $chunk_data, $chunk_size);
109 75 50 0     87 $bytes == $chunk_size or die "$!" || "Unexpected EOF in alias file";
110            
111 75         63 my $key = $item_offset;
112 75 100       81 if ($item_type) {
113 55         93 $key = $ITEM_TYPES{$item_type};
114 55   33     65 $key //= sprintf '%#x', $item_type;
115             }
116 75         58 my $parsed;
117            
118 75 100       159 if ($chunk_type == -2) { # TOC
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
119 4         5 my ($level, $count);
120 4         8 ($level, $next, $count) = unpack 'V V V', $chunk_data;
121 4         10 for my $i (reverse 1 .. $count) {
122             # It just so happens that the TOC header is the same
123             # length as a TOC item, so we start counting at 1
124 55         61 my $item = substr $chunk_data, 12 * $i, 12;
125 55         64 push @toc, $item;
126             }
127 4         10 push @data, $data = {};
128 4         6 ($key, $parsed) = (level => $level);
129             }
130            
131             elsif (0x0101 == ($chunk_type & 0xf7ff)) { # string / URL
132 26         36 $parsed = substr $chunk_data, 0, $chunk_size;
133 26         47 utf8::decode $parsed;
134             }
135            
136             elsif (0x0201 == $chunk_type) { # structured data
137 10         18 $parsed = $chunk_data; # TODO
138             }
139            
140             elsif (0x0303 == $chunk_type) { # 32-bit integer
141 7         9 $parsed = unpack 'l<', $chunk_data;
142            
143             # Make readable names available for creationOptions
144 7 100 66     20 if ($item_type && $item_type == 0xd010) {
145 3         10 my %options = map { $_ => $parsed }
146 3         12 grep { $CREATION_OPTIONS{$_} & $parsed }
  18         24  
147             keys %CREATION_OPTIONS;
148 3 50       9 $parsed = %options ? \%options : { $parsed => $parsed };
149             }
150             }
151            
152             elsif (0x0304 == $chunk_type) { # 64-bit integer
153 8         9 $parsed = $chunk_data;
154 8         8 eval { $parsed = unpack 'q<', $chunk_data; };
  8         13  
155             # eval because unpack 'q' will fail on 32-bit Perls
156             }
157            
158             elsif (0x0400 == $chunk_type) { # timestamp
159 7         11 $parsed = $EPOCH_OFFSET + unpack 'd>', $chunk_data;
160             }
161            
162             elsif (0x0500 == ($chunk_type & 0xff00)) { # boolean
163 5         6 $parsed = !! ($chunk_type & 0x00ff);
164             }
165            
166             elsif (0x0601 == $chunk_type) { # path
167 6         11 $data->{$key} = [];
168 6         8 my $path_count = $chunk_size / 4;
169 6         29 my @path = unpack "V[$path_count]", $chunk_data;
170 6         12 push @toc, map { [ $_, $data->{$key} ] } reverse @path;
  16         36  
171 6         31 next;
172             }
173            
174             elsif (0x0a01 == $chunk_type) { # null
175 2         4 $parsed = undef;
176             }
177            
178             else {
179 0         0 croak sprintf 'Alias file chunk type %#06x unsupported at offset %i (item %#x) in file %s',
180             $chunk_type, $item_offset, $item_type, $file;
181             }
182            
183 69 100       91 if (ref $item_ref eq 'ARRAY') {
184 16         47 push @$item_ref, $parsed;
185             }
186             else {
187 53         167 $data->{$key} = $parsed;
188             }
189             }
190            
191 3         40 close $fh;
192            
193 3         10 $data[0]->{header} = $header;
194 3         6 $_->{path} = '/' . join '/', $_->{pathComponents}->@* for grep { $_->{pathComponents} } @data;
  4         21  
195 3         10 for my $i (1 .. $#data) {
196 1         6 $data[$i - 1]->{next} = $data[$i];
197             }
198 3         28 return $data[0];
199            
200             }
201              
202              
203             sub is_alias :prototype($) {
204 5     5 1 1161 my ($file) = @_;
205            
206             # Try to read data fork alias magic number
207 5 50       168 open my $fh, '<:raw', $file or return;
208 5 100       99 read $fh, my $data, 16 or return;
209 4         54 close $fh;
210 4         30 return $data eq $MAGIC;
211             }
212              
213              
214             sub read_alias_perl :prototype($) {
215 10     10 1 77 my ($file) = @_;
216            
217 10 50       367 open my $fh, '<:raw', $file or return;
218 10 100       173 read $fh, my $header, 20 or return;
219 8 100       60 $MAGIC eq substr $header, 0, 16 or return;
220            
221             # read header
222 6 50       26 my $start = unpack 'V', substr $header, -4 or return;
223 6         83 seek $fh, $start, SEEK_SET;
224 6 50       49 read $fh, my $toc_offset, 4 or return;
225 6         21 $toc_offset = unpack 'V', $toc_offset;
226            
227             # read TOC
228 6         7 my $path_offset;
229 6         50 seek $fh, $start + $toc_offset + 20, SEEK_SET;
230 6         47 while (read $fh, my $item, 12) {
231 6         9 my $item_type;
232 6         14 ($item_type, $path_offset) = unpack 'V V', $item;
233 6 50       18 last if $item_type == 0x1004; # pathComponents
234             }
235 6 50       10 $path_offset or return;
236            
237             # read path list
238 6         48 seek $fh, $start + $path_offset, SEEK_SET;
239 6 50       43 read $fh, my $path_header, 8 or return;
240 6         19 my ($path_size, $path_type) = unpack 'V l<', $path_header;
241 6 50       14 $path_type == 0x0601 or return;
242 6 50       14 defined read $fh, my $path_chunk, $path_size or return;
243 6         11 my $path_count = $path_size / 4;
244 6         33 my @path_offsets = unpack "V[$path_count]", $path_chunk;
245            
246             # read path elements
247 6         24 my $path = path('/');
248 6         243 while ( my $offset = shift @path_offsets ) {
249 12         424 seek $fh, $start + $offset, SEEK_SET;
250 12 50       92 read $fh, my $chunk_header, 8 or return;
251 12         52 my ($chunk_size, $chunk_type) = unpack 'V l<', $chunk_header;
252 12 50       23 $chunk_type == 0x0101 or return;
253 12 50       22 read $fh, my $chunk_data, $chunk_size or return;
254 12         34 utf8::decode $chunk_data;
255 12         30 $path = $path->child($chunk_data);
256             }
257 6         207 return $path;
258             }
259              
260              
261             our %_osascript;
262             my %SCRIPT_SRC = (
263             resolve_alias => <<'EOF',
264             on run argv
265             set thePath to item 1 of argv
266             set theAlias to (POSIX file thePath) as alias
267             tell application "Finder"
268             if kind of theAlias is "Alias" then
269             return POSIX path of ((original item of theAlias) as alias)
270             end if
271             end tell
272             end run
273             EOF
274             create_alias => <<'EOF',
275             on run argv
276             set theTarget to item 1 of argv
277             set theFolder to item 2 of argv
278             set theName to item 3 of argv
279             set theAlias to missing value
280             try
281             tell application "Finder"
282             make alias file to (POSIX file theTarget) at (POSIX file theFolder)
283             set theAlias to the result
284             set name of the result to theName
285             end tell
286             on error errStr number errNum
287             if theAlias is not missing value then
288             -- Undo make alias
289             set theVolume to output volume of (get volume settings)
290             set volume output volume 0 -- Silence UI sound effects
291             tell application "System Events" to delete theAlias
292             set volume output volume theVolume
293             end if
294             error errStr number errNum
295             end try
296             end run
297             EOF
298             );
299              
300              
301             # Compile AppleScript just-in-time into a temp file when needed
302             # (because execution of compiled scripts is slightly faster)
303             sub _osascript :prototype($) {
304 11     11   28 my ($scriptname) = @_;
305            
306 11 50 33     138 return unless -x '/usr/bin/osacompile' && -x '/usr/bin/osascript';
307 0   0     0 $_osascript{_dir} //= Path::Tiny->tempdir('Mac-Alias-XXXXXXXX');
308 0         0 my $source = $_osascript{_dir}->child("$scriptname.applescript");
309 0         0 my $compiled = $_osascript{_dir}->child("$scriptname.scpt");
310 0         0 $source->spew($SCRIPT_SRC{$scriptname});
311 0         0 my $out = qx(osacompile -x -o "$compiled" "$source" 2>&1);
312 0 0       0 if ($?) {
313 0         0 chomp $out;
314 0         0 warn $out;
315 0         0 return;
316             }
317 0         0 return $_osascript{$scriptname} = $compiled;
318             }
319              
320              
321             sub read_alias_mac :prototype($) {
322 5     5 1 2541 my ($file) = @_;
323            
324 5         15 $file = path($file)->realpath =~ s/(["`\$\\])/\\$1/gr;
325 5   33     846 my $script = $_osascript{resolve_alias} // _osascript 'resolve_alias';
326 5 50       25 if ( ! $script ) {
327 5         62 carp "Failed to read alias using Mac-only function";
328 5         1491 return;
329             }
330            
331 0         0 my $out = qx(osascript -so "$script" "$file");
332 0         0 chomp $out;
333 0         0 utf8::decode($out);
334 0 0 0     0 if ( ! $out || $? ) {
335 0 0 0     0 carp $out if $out && $out !~ m/\(-1700\)/; # -1700 = can't find original
336 0         0 return;
337             }
338 0         0 return path($out);
339             }
340              
341              
342             sub read_alias :prototype($) {
343 5     5 1 3952 my ($file) = @_;
344            
345 5         12 my $target = read_alias_perl $file;
346 5 100 100     30 return $target if $target && $target->exists;
347            
348 4 50 33     87 my $script = $_osascript{resolve_alias} // _osascript 'resolve_alias'
349             or return $target;
350 0         0 $file = path($file)->realpath =~ s/(["`\$\\])/\\$1/gr;
351            
352 0         0 my $out = qx(osascript -so "$script" "$file");
353 0         0 chomp $out;
354 0         0 utf8::decode($out);
355 0 0 0     0 return path($out) if $out && ! $?;
356 0         0 return $target;
357             }
358              
359              
360             sub make_alias :prototype($$) {
361 3     3 1 18169 my ($target, $alias) = @_;
362            
363 3 100       45 if ( ! -e $target ) {
364 1         37 carp "Failed to make alias to $target: File not found";
365 1         424 return;
366             }
367            
368 2   33     35 my $script = $_osascript{create_alias} // _osascript 'create_alias';
369 2 50       6 if ( ! $script ) {
370 2         21 carp "Failed to make alias using Mac-only function";
371 2         556 return;
372             }
373 0           $target = path($target)->realpath =~ s/(["`\$\\])/\\$1/gr;
374 0           $alias = path($alias)->realpath;
375 0           my $folder = $alias->parent =~ s/(["`\$\\])/\\$1/gr;
376 0           my $name = $alias->basename =~ s/(["`\$\\])/\\$1/gr;
377            
378 0           my $out = qx(osascript -so "$script" "$target" "$folder" "$name");
379 0 0         if ($?) {
380 0           chomp $out;
381 0           carp $out;
382 0           return;
383             }
384 0           return 1;
385             }
386              
387              
388             1;
389              
390             __END__