File Coverage

blib/lib/Image/ExifTool/MacOS.pm
Criterion Covered Total %
statement 79 248 31.8
branch 21 150 14.0
condition 10 50 20.0
subroutine 6 12 50.0
pod 0 9 0.0
total 116 469 24.7


line stmt bran cond sub pod time code
1             #------------------------------------------------------------------------------
2             # File: MacOS.pm
3             #
4             # Description: Read/write MacOS system tags
5             #
6             # Revisions: 2017/03/01 - P. Harvey Created
7             # 2020/10/13 - PH Added ability to read MacOS "._" files
8             #------------------------------------------------------------------------------
9              
10             package Image::ExifTool::MacOS;
11 43     43   7998 use strict;
  43         97  
  43         2202  
12 43     43   262 use vars qw($VERSION);
  43         96  
  43         2772  
13 43     43   268 use Image::ExifTool qw(:DataAccess :Utils);
  43         119  
  43         249465  
14              
15             $VERSION = '1.15';
16              
17             sub MDItemLocalTime($);
18             sub ProcessATTR($$$);
19              
20             my %mdDateInfo = (
21             ValueConv => \&MDItemLocalTime,
22             PrintConv => '$self->ConvertDateTime($val)',
23             );
24              
25             my %delXAttr = (
26             XAttrQuarantine => 'com.apple.quarantine',
27             XAttrMDItemWhereFroms => 'com.apple.metadata:kMDItemWhereFroms',
28             );
29              
30             # Information decoded from Mac OS sidecar files
31             %Image::ExifTool::MacOS::Main = (
32             GROUPS => { 0 => 'File', 1 => 'MacOS' },
33             NOTES => q{
34             Note that on some filesystems, MacOS creates sidecar files with names that
35             begin with "._". ExifTool will read these files if specified, and extract
36             the information listed in the following table without the need for extra
37             options, but these files are not writable directly.
38             },
39             2 => {
40             Name => 'RSRC',
41             SubDirectory => { TagTable => 'Image::ExifTool::RSRC::Main' },
42             },
43             9 => {
44             Name => 'ATTR',
45             SubDirectory => {
46             TagTable => 'Image::ExifTool::MacOS::XAttr',
47             ProcessProc => \&ProcessATTR,
48             },
49             },
50             );
51              
52             # "mdls" tags (ref PH)
53             %Image::ExifTool::MacOS::MDItem = (
54             WRITE_PROC => \&Image::ExifTool::DummyWriteProc,
55             VARS => { ID_FMT => 'none' },
56             GROUPS => { 0 => 'File', 1 => 'MacOS', 2 => 'Other' },
57             NOTES => q{
58             MDItem tags are extracted using the "mdls" utility. They are extracted if
59             any "MDItem*" tag or the MacOS group is specifically requested, or by
60             setting the API L option to 1 or the API L option to 2 or
61             higher. Note that these tags do not necessarily reflect the current
62             metadata of a file -- it may take some time for the MacOS mdworker daemon to
63             index the file after a metadata change.
64             },
65             MDItemFinderComment => {
66             Writable => 1,
67             WritePseudo => 1,
68             Protected => 1, # (all writable pseudo tags must be protected)
69             },
70             MDItemFSLabel => {
71             Writable => 1,
72             WritePseudo => 1,
73             Protected => 1, # (all writable pseudo tags must be protected)
74             WriteCheck => '$val =~ /^[0-7]$/ ? undef : "Not an integer in the range 0-7"',
75             PrintConv => {
76             0 => '0 (none)',
77             1 => '1 (Gray)',
78             2 => '2 (Green)',
79             3 => '3 (Purple)',
80             4 => '4 (Blue)',
81             5 => '5 (Yellow)',
82             6 => '6 (Red)',
83             7 => '7 (Orange)',
84             },
85             },
86             MDItemFSCreationDate => {
87             Writable => 1,
88             WritePseudo => 1,
89             DelCheck => q{"Can't delete"},
90             Protected => 1, # (all writable pseudo tags must be protected)
91             Shift => 'Time', # (but not supported yet)
92             Notes => q{
93             file creation date. Requires "setfile" for writing. Note that when
94             reading, it may take a few seconds after writing a file before this value
95             reflects the change. However, L is updated immediately
96             },
97             Groups => { 2 => 'Time' },
98             ValueConv => \&MDItemLocalTime,
99             ValueConvInv => '$val',
100             PrintConv => '$self->ConvertDateTime($val)',
101             PrintConvInv => '$self->InverseDateTime($val)',
102             },
103             MDItemAcquisitionMake => { Groups => { 2 => 'Camera' } },
104             MDItemAcquisitionModel => { Groups => { 2 => 'Camera' } },
105             MDItemAltitude => { Groups => { 2 => 'Location' } },
106             MDItemAperture => { Groups => { 2 => 'Camera' } },
107             MDItemAudioBitRate => { Groups => { 2 => 'Audio' } },
108             MDItemAudioChannelCount => { Groups => { 2 => 'Audio' } },
109             MDItemAuthors => { Groups => { 2 => 'Author' } },
110             MDItemBitsPerSample => { Groups => { 2 => 'Image' } },
111             MDItemCity => { Groups => { 2 => 'Location' } },
112             MDItemCodecs => { },
113             MDItemColorSpace => { Groups => { 2 => 'Image' } },
114             MDItemComment => { },
115             MDItemContentCreationDate => { Groups => { 2 => 'Time' }, %mdDateInfo },
116             MDItemContentCreationDateRanking => { Groups => { 2 => 'Time' }, %mdDateInfo },
117             MDItemContentModificationDate => { Groups => { 2 => 'Time' }, %mdDateInfo },
118             MDItemContentType => { },
119             MDItemContentTypeTree => { },
120             MDItemContributors => { },
121             MDItemCopyright => { Groups => { 2 => 'Author' } },
122             MDItemCountry => { Groups => { 2 => 'Location' } },
123             MDItemCreator => { Groups => { 2 => 'Document' } },
124             MDItemDateAdded => { Groups => { 2 => 'Time' }, %mdDateInfo },
125             MDItemDescription => { },
126             MDItemDisplayName => { },
127             MDItemDownloadedDate => { Groups => { 2 => 'Time' }, %mdDateInfo },
128             MDItemDurationSeconds => { PrintConv => 'ConvertDuration($val)' },
129             MDItemEncodingApplications => { },
130             MDItemEXIFGPSVersion => { Groups => { 2 => 'Location' }, Description => 'MD Item EXIF GPS Version' },
131             MDItemEXIFVersion => { },
132             MDItemExposureMode => { Groups => { 2 => 'Camera' } },
133             MDItemExposureProgram => { Groups => { 2 => 'Camera' } },
134             MDItemExposureTimeSeconds => { Groups => { 2 => 'Camera' } },
135             MDItemFlashOnOff => { Groups => { 2 => 'Camera' } },
136             MDItemFNumber => { Groups => { 2 => 'Camera' } },
137             MDItemFocalLength => { Groups => { 2 => 'Camera' } },
138             MDItemFSContentChangeDate => { Groups => { 2 => 'Time' }, %mdDateInfo },
139             MDItemFSCreatorCode => { Groups => { 2 => 'Author' } },
140             MDItemFSFinderFlags => { },
141             MDItemFSHasCustomIcon => { },
142             MDItemFSInvisible => { },
143             MDItemFSIsExtensionHidden => { },
144             MDItemFSIsStationery => { },
145             MDItemFSName => { },
146             MDItemFSNodeCount => { },
147             MDItemFSOwnerGroupID => { },
148             MDItemFSOwnerUserID => { },
149             MDItemFSSize => { },
150             MDItemFSTypeCode => { },
151             MDItemGPSDateStamp => { Groups => { 2 => 'Time' } },
152             MDItemGPSStatus => { Groups => { 2 => 'Location' } },
153             MDItemGPSTrack => { Groups => { 2 => 'Location' } },
154             MDItemHasAlphaChannel => { Groups => { 2 => 'Image' } },
155             MDItemImageDirection => { Groups => { 2 => 'Location' } },
156             MDItemInterestingDateRanking => { Groups => { 2 => 'Time' }, %mdDateInfo },
157             MDItemISOSpeed => { Groups => { 2 => 'Camera' } },
158             MDItemKeywords => { },
159             MDItemKind => { },
160             MDItemLastUsedDate => { Groups => { 2 => 'Time' }, %mdDateInfo },
161             MDItemLastUsedDate_Ranking => { },
162             MDItemLatitude => { Groups => { 2 => 'Location' } },
163             MDItemLensModel => { },
164             MDItemLogicalSize => { },
165             MDItemLongitude => { Groups => { 2 => 'Location' } },
166             MDItemMediaTypes => { },
167             MDItemNumberOfPages => { },
168             MDItemOrientation => { Groups => { 2 => 'Image' } },
169             MDItemOriginApplicationIdentifier => { },
170             MDItemOriginMessageID => { },
171             MDItemOriginSenderDisplayName => { },
172             MDItemOriginSenderHandle => { },
173             MDItemOriginSubject => { },
174             MDItemPageHeight => { Groups => { 2 => 'Image' } },
175             MDItemPageWidth => { Groups => { 2 => 'Image' } },
176             MDItemPhysicalSize => { Groups => { 2 => 'Image' } },
177             MDItemPixelCount => { Groups => { 2 => 'Image' } },
178             MDItemPixelHeight => { Groups => { 2 => 'Image' } },
179             MDItemPixelWidth => { Groups => { 2 => 'Image' } },
180             MDItemProfileName => { Groups => { 2 => 'Image' } },
181             MDItemRedEyeOnOff => { Groups => { 2 => 'Camera' } },
182             MDItemResolutionHeightDPI => { Groups => { 2 => 'Image' } },
183             MDItemResolutionWidthDPI => { Groups => { 2 => 'Image' } },
184             MDItemSecurityMethod => { },
185             MDItemSpeed => { Groups => { 2 => 'Location' } },
186             MDItemStateOrProvince => { Groups => { 2 => 'Location' } },
187             MDItemStreamable => { },
188             MDItemTimestamp => { Groups => { 2 => 'Time' } }, # (time only)
189             MDItemTitle => { },
190             MDItemTotalBitRate => { },
191             MDItemUseCount => { },
192             MDItemUsedDates => { Groups => { 2 => 'Time' }, %mdDateInfo },
193             MDItemUserDownloadedDate => { Groups => { 2 => 'Time' }, %mdDateInfo },
194             MDItemUserDownloadedUserHandle=> { },
195             MDItemUserSharedReceivedDate => { },
196             MDItemUserSharedReceivedRecipient => { },
197             MDItemUserSharedReceivedRecipientHandle => { },
198             MDItemUserSharedReceivedSender=> { },
199             MDItemUserSharedReceivedSenderHandle => { },
200             MDItemUserSharedReceivedTransport => { },
201             MDItemUserTags => {
202             List => 1,
203             Writable => 1,
204             WritePseudo => 1,
205             Protected => 1, # (all writable pseudo tags must be protected)
206             Notes => q{
207             requires "tag" utility for writing -- install with "brew install tag". Note
208             that user tags may not contain a comma, and that duplicate user tags will
209             not be written
210             },
211             },
212             MDItemVersion => { },
213             MDItemVideoBitRate => { Groups => { 2 => 'Video' } },
214             MDItemWhereFroms => { },
215             MDItemWhiteBalance => { Groups => { 2 => 'Image' } },
216             # tags used by Apple Mail on .emlx files
217             com_apple_mail_dateReceived => { Name => 'AppleMailDateReceived', Groups => { 2 => 'Time' }, %mdDateInfo },
218             com_apple_mail_dateSent => { Name => 'AppleMailDateSent', Groups => { 2 => 'Time' }, %mdDateInfo },
219             com_apple_mail_flagged => { Name => 'AppleMailFlagged' },
220             com_apple_mail_messageID => { Name => 'AppleMailMessageID' },
221             com_apple_mail_priority => { Name => 'AppleMailPriority' },
222             com_apple_mail_read => { Name => 'AppleMailRead' },
223             com_apple_mail_repliedTo => { Name => 'AppleMailRepliedTo' },
224             com_apple_mail_isRemoteAttachment => { Name => 'AppleMailIsRemoteAttachment' },
225             MDItemAccountHandles => { },
226             MDItemAccountIdentifier => { },
227             MDItemAuthorEmailAddresses => { },
228             MDItemBundleIdentifier => { },
229             MDItemContentCreationDate_Ranking=>{Groups=> { 2 => 'Time' }, %mdDateInfo },
230             MDItemDateAdded_Ranking => { Groups => { 2 => 'Time' }, %mdDateInfo },
231             MDItemEmailConversationID => { },
232             MDItemIdentifier => { },
233             MDItemInterestingDate_Ranking => { Groups => { 2 => 'Time' }, %mdDateInfo },
234             MDItemIsApplicationManaged => { },
235             MDItemIsExistingThread => { },
236             MDItemIsLikelyJunk => { },
237             MDItemMailboxes => { },
238             MDItemMailDateReceived_Ranking=> { Groups => { 2 => 'Time' }, %mdDateInfo },
239             MDItemPrimaryRecipientEmailAddresses => { },
240             MDItemRecipients => { },
241             MDItemSubject => { },
242             );
243              
244             # "xattr" tags
245             %Image::ExifTool::MacOS::XAttr = (
246             WRITE_PROC => \&Image::ExifTool::DummyWriteProc,
247             GROUPS => { 0 => 'File', 1 => 'MacOS', 2 => 'Other' },
248             VARS => { ID_FMT => 'none' }, # (id's are too long)
249             NOTES => q{
250             XAttr tags are extracted using the "xattr" utility. They are extracted if
251             any "XAttr*" tag or the MacOS group is specifically requested, or by setting
252             the API L option to 1 or the API L option to 2 or higher.
253             And they are extracted by default from MacOS "._" files when reading
254             these files directly.
255             },
256             'com.apple.FinderInfo' => {
257             Name => 'XAttrFinderInfo',
258             ConvertBinary => 1,
259             # ref https://opensource.apple.com/source/CarbonHeaders/CarbonHeaders-9A581/Finder.h
260             ValueConv => q{
261             my @a = unpack('a4a4n3x10nx2N', $$val);
262             tr/\0//d, $_="'${_}'" foreach @a[0,1];
263             return "@a";
264             },
265             PrintConv => q{
266             $val =~ s/^('.*?') ('.*?') //s or return $val;
267             my ($type, $creator) = ($1, $2);
268             my ($flags, $y, $x, $exFlags, $putAway) = split ' ', $val;
269             my $label = ($flags >> 1) & 0x07;
270             my $flags = DecodeBits((($exFlags<<16) | $flags) & 0xfff1, {
271             0 => 'OnDesk',
272             6 => 'Shared',
273             7 => 'HasNoInits',
274             8 => 'Inited',
275             10 => 'CustomIcon',
276             11 => 'Stationery',
277             12 => 'NameLocked',
278             13 => 'HasBundle',
279             14 => 'Invisible',
280             15 => 'Alias',
281             # extended flags
282             22 => 'HasRoutingInfo',
283             23 => 'ObjectBusy',
284             24 => 'CustomBadge',
285             31 => 'ExtendedFlagsValid',
286             });
287             my $str = "Type=$type Creator=$creator Flags=$flags Label=$label Pos=($x,$y)";
288             $str .= " Putaway=$putAway" if $putAway;
289             return $str;
290             },
291             },
292             'com.apple.quarantine' => {
293             Name => 'XAttrQuarantine',
294             Writable => 1,
295             WritePseudo => 1,
296             WriteCheck => '"May only delete this tag"',
297             Protected => 1,
298             Notes => q{
299             quarantine information for files downloaded from the internet. May only be
300             deleted when writing
301             },
302             # ($a[1] is the time when the quarantine tag was set)
303             PrintConv => q{
304             my @a = split /;/, $val;
305             $a[0] = 'Flags=' . $a[0];
306             $a[1] = 'set at ' . ConvertUnixTime(hex $a[1]);
307             $a[2] = 'by ' . $a[2];
308             return join ' ', @a;
309             },
310             PrintConvInv => '$val',
311             },
312             'com.apple.metadata:com_apple_mail_dateReceived' => {
313             Name => 'XAttrAppleMailDateReceived',
314             Groups => { 2 => 'Time' },
315             },
316             'com.apple.metadata:com_apple_mail_dateSent' => {
317             Name => 'XAttrAppleMailDateSent',
318             Groups => { 2 => 'Time' },
319             },
320             'com.apple.metadata:com_apple_mail_isRemoteAttachment' => {
321             Name => 'XAttrAppleMailIsRemoteAttachment',
322             },
323             'com.apple.metadata:kMDItemDownloadedDate' => {
324             Name => 'XAttrMDItemDownloadedDate',
325             Groups => { 2 => 'Time' },
326             },
327             'com.apple.metadata:kMDItemFinderComment' => { Name => 'XAttrMDItemFinderComment' },
328             'com.apple.metadata:kMDItemWhereFroms' => {
329             Name => 'XAttrMDItemWhereFroms',
330             Writable => 1,
331             WritePseudo => 1,
332             WriteCheck => '"May only delete this tag"',
333             Protected => 1,
334             Notes => q{
335             information about where the file came from. May only be deleted when
336             writing
337             },
338             },
339             'com.apple.metadata:kMDLabel' => { Name => 'XAttrMDLabel', Binary => 1 },
340             'com.apple.ResourceFork' => { Name => 'XAttrResourceFork', Binary => 1 },
341             'com.apple.lastuseddate#PS' => {
342             Name => 'XAttrLastUsedDate',
343             Groups => { 2 => 'Time' },
344             # (first 4 bytes are date/time. Not sure what remaining 12 bytes are for)
345             RawConv => 'ConvertUnixTime(unpack("V",$$val))',
346             PrintConv => '$self->ConvertDateTime($val)',
347             },
348             );
349              
350             #------------------------------------------------------------------------------
351             # Convert OS MDItem time string to standard EXIF-formatted local time
352             # Inputs: 0) time string (eg. "2017-02-21 17:21:43 +0000")
353             # Returns: EXIF-formatted local time string with timezone
354             sub MDItemLocalTime($)
355             {
356 0     0 0 0 my $val = shift;
357 0         0 $val =~ tr/-/:/;
358 0         0 $val =~ s/ ?([-+]\d{2}):?(\d{2})/$1:$2/;
359             # convert from UTC to local time
360 0 0       0 if ($val =~ /\+00:00$/) {
361 0         0 my $time = Image::ExifTool::GetUnixTime($val);
362 0 0       0 $val = Image::ExifTool::ConvertUnixTime($time, 1) if $time;
363             }
364 0         0 return $val;
365             }
366              
367             #------------------------------------------------------------------------------
368             # Call system command, redirecting all I/O to /dev/null
369             # Inputs: system arguments
370             # Returns: system return code
371             sub System
372             {
373 0     0 0 0 my ($oldout, $olderr);
374 0         0 open($oldout, ">&STDOUT");
375 0         0 open($olderr, ">&STDERR");
376 0         0 open(STDOUT, '>', '/dev/null');
377 0         0 open(STDERR, '>', '/dev/null');
378 0         0 my $result = system(@_);
379 0         0 open(STDOUT, ">&", $oldout);
380 0         0 open(STDERR, ">&", $olderr);
381 0         0 return $result;
382             }
383              
384             #------------------------------------------------------------------------------
385             # Set MacOS MDItem and XAttr tags from new tag values
386             # Inputs: 0) ExifTool ref, 1) file name, 2) list of tags to set
387             # Returns: 1=something was set OK, 0=didn't try, -1=error (and warning set)
388             # Notes: There may be errors even if 1 is returned
389             sub SetMacOSTags($$$)
390             {
391 0     0 0 0 my ($et, $file, $setTags) = @_;
392 0         0 my $result = 0;
393 0         0 my $tag;
394              
395 0         0 foreach $tag (@$setTags) {
396 0         0 my ($nvHash, $attr, @cmd, $err, $silentErr);
397 0         0 my $val = $et->GetNewValue($tag, \$nvHash);
398 0 0       0 next unless $nvHash;
399 0         0 my $overwrite = $et->IsOverwriting($nvHash);
400 0 0       0 unless ($$nvHash{TagInfo}{List}) {
401 0 0       0 next unless $overwrite;
402 0 0       0 if ($overwrite < 0) {
403 0 0       0 my $operation = $$nvHash{Shift} ? 'Shifting' : 'Conditional replacement';
404 0         0 $et->Warn("$operation of MacOS $tag not yet supported");
405 0         0 next;
406             }
407             }
408 0 0 0     0 if ($tag eq 'MDItemFSCreationDate' or $tag eq 'FileCreateDate') {
    0          
    0          
409             # convert to local time if value has a time zone
410 0 0       0 if ($val =~ /[-+Z]/) {
411 0         0 my $time = Image::ExifTool::GetUnixTime($val, 1);
412 0 0       0 $val = Image::ExifTool::ConvertUnixTime($time, 1) if $time;
413 0         0 $val =~ s/[-+].*//; # remove time zone
414             }
415 0         0 $val =~ s{(\d{4}):(\d{2}):(\d{2})}{$2/$3/$1}; # reformat for setfile
416 0         0 push @cmd, '/usr/bin/setfile', '-d', $val, $file;
417             } elsif ($tag eq 'MDItemUserTags') {
418             # (tested with "tag" version 0.9.0)
419 0         0 my @vals = $et->GetNewValue($nvHash);
420 0 0 0     0 if ($overwrite < 0 and @{$$nvHash{DelValue}}) {
  0         0  
421             # delete specified tags
422 0         0 my @dels = @{$$nvHash{DelValue}};
  0         0  
423 0         0 my $del = join ',', @dels;
424 0         0 $err = System('/usr/local/bin/tag', '-r', $del, $file);
425 0 0       0 unless ($err) {
426 0         0 $et->VerboseValue("- $tag", $del);
427 0         0 $result = 1;
428 0 0       0 undef $err if @vals; # more to do if there are tags to add
429             }
430             }
431 0 0       0 unless (defined $err) {
432             # add new tags, or overwrite or delete existing tags
433 0 0       0 my $opt = $overwrite > 0 ? '-s' : '-a';
434 0 0       0 $val = @vals ? join(',', @vals) : '';
435 0         0 push @cmd, '/usr/local/bin/tag', $opt, $val, $file;
436 0 0       0 $et->VPrint(1," - $tag = (all)\n") if $overwrite > 0;
437 0 0       0 undef $val if $val eq '';
438             }
439             } elsif ($delXAttr{$tag}) {
440 0         0 push @cmd, '/usr/bin/xattr', '-d', $delXAttr{$tag}, $file;
441 0         0 $silentErr = 256; # (will get this error if attribute doesn't exist)
442             } else {
443 0         0 my ($f, $v);
444 0         0 ($f = $file) =~ s/([\\"])/\\$1/g; # escape backslashes and quotes for AppleScript
445 0 0       0 if ($tag eq 'MDItemFinderComment') {
446             # (write finder comment using osascript instead of xattr
447             # because it is more work to construct the necessary bplist)
448 0 0       0 $val = '' unless defined $val; # set to empty string instead of deleting
449 0         0 $v = $et->Encode($val, 'UTF8');
450 0         0 $v =~ s/([\\"])/\\$1/g;
451 0         0 $attr = 'comment';
452             } else { # $tag eq 'MDItemFSLabel'
453 0 0       0 $v = $val ? 8 - $val : 0; # convert from label to label index (0 for no label)
454 0         0 $attr = 'label index';
455             }
456 0         0 push @cmd, '/usr/bin/osascript', '-e', qq(set fp to POSIX file "$f" as alias),
457             '-e', qq(tell application "Finder" to set $attr of file fp to "$v");
458             }
459 0 0       0 $err = System(@cmd) if @cmd;
460 0 0 0     0 if (not $err) {
    0          
461 0 0       0 $et->VerboseValue("+ $tag", $val) if defined $val;
462 0         0 $result = 1;
463             } elsif (not $silentErr or $err != $silentErr) {
464 0   0     0 my $cmd = $cmd[0] || 'tag';
465 0         0 $cmd =~ s(.*/)();
466 0         0 $et->Warn(qq{Error $err running "$cmd" to set $tag});
467 0 0       0 $result = -1 unless $result;
468             }
469             }
470 0         0 return $result;
471             }
472              
473             #------------------------------------------------------------------------------
474             # Extract MacOS metadata item tags
475             # Inputs: 0) ExifTool object ref, 1) file name
476             sub ExtractMDItemTags($$)
477             {
478 0     0 0 0 local $_;
479 0         0 my ($et, $file) = @_;
480 0         0 my ($fn, $tag, $val, $tmp);
481              
482 0         0 ($fn = $file) =~ s/([`"\$\\])/\\$1/g; # escape necessary characters
483 0         0 $et->VPrint(0, '(running mdls)');
484 0         0 my @mdls = `/usr/bin/mdls "$fn" 2> /dev/null`; # get MacOS metadata
485 0 0 0     0 if ($? or not @mdls) {
486 0         0 $et->Warn('Error running "mdls" to extract MDItem tags');
487 0         0 return;
488             }
489 0         0 my $tagTablePtr = GetTagTable('Image::ExifTool::MacOS::MDItem');
490 0         0 $$et{INDENT} .= '| ';
491 0         0 $et->VerboseDir('MDItem');
492 0         0 foreach (@mdls) {
493 0         0 chomp;
494 0 0       0 if (ref $val ne 'ARRAY') {
    0          
495 0 0       0 s/^k?(\w+)\s*= // or next;
496 0         0 $tag = $1;
497 0 0       0 $_ eq '(' and $val = [ ], next; # (start of a list)
498 0 0       0 $_ = '' if $_ eq '(null)';
499 0 0       0 s/^"// and s/"$//; # remove quotes if they exist
500 0         0 $val = $_;
501             } elsif ($_ eq ')') { # (end of a list)
502 0         0 $_ = $$val[0];
503 0 0       0 next unless defined $_;
504             } else {
505             # add item to list
506 0         0 s/^ //; # remove leading spaces
507 0         0 s/,$//; # remove trailing comma
508 0 0       0 $_ = '' if $_ eq '(null)';
509 0 0       0 s/^"// and s/"$//; # remove quotes if they exist
510 0         0 s/\\"/"/g; # un-escape quotes
511 0         0 s/\\\\/\\/g; # un-escape backslashes
512 0         0 $_ = $et->Decode($_, 'UTF8');
513 0         0 push @$val, $_;
514 0         0 next;
515             }
516             # add to Extra tags if not done already
517 0 0       0 unless ($$tagTablePtr{$tag}) {
518             # check for a date/time format
519 0         0 my %tagInfo;
520 0 0       0 %tagInfo = (
521             Groups => { 2 => 'Time' },
522             ValueConv => \&MDItemLocalTime,
523             PrintConv => '$self->ConvertDateTime($val)',
524             ) if /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/;
525             # change tags like "com_apple_mail_xxx" to "AppleMailXxx"
526 0         0 ($tmp = $tag) =~ s/^com_//; # remove leading "com_"
527 0         0 $tmp =~ s/_([a-z])/\u$1/g; # use CamelCase
528 0         0 $tagInfo{Name} = Image::ExifTool::MakeTagName($tmp);
529 0 0       0 $tagInfo{List} = 1 if ref $val eq 'ARRAY';
530 0 0       0 $tagInfo{Groups}{2} = 'Audio' if $tag =~ /Audio/;
531 0 0       0 $tagInfo{Groups}{2} = 'Author' if $tag =~ /(Copyright|Author)/;
532 0         0 $et->VPrint(0, " [adding $tag]\n");
533 0         0 AddTagToTable($tagTablePtr, $tag, \%tagInfo);
534             }
535 0 0       0 $val = $et->Decode($val, 'UTF8') unless ref $val;
536 0         0 $et->HandleTag($tagTablePtr, $tag, $val);
537 0         0 undef $val;
538             }
539 0         0 $$et{INDENT} =~ s/\| $//;
540             }
541              
542             #------------------------------------------------------------------------------
543             # Read MacOS XAttr value
544             # Inputs: 0) ExifTool object ref, 1) file name
545             sub ReadXAttrValue($$$$)
546             {
547 8     8 0 38 my ($et, $tagTablePtr, $tag, $val) = @_;
548             # add to our table if necessary
549 8 100       37 unless ($$tagTablePtr{$tag}) {
550 2         5 my $name;
551             # generate tag name from attribute name
552 2 100       12 if ($tag =~ /^com\.apple\.(.*)$/) {
553 1         10 ($name = $1) =~ s/^metadata:_?k//;
554 1         4 $name =~ s/^metadata:(com_)?//;
555             } else {
556 1         2 $name = $tag;
557             }
558 2         17 $name =~ s/[.:_]([a-z])/\U$1/g;
559 2         8 $name = 'XAttr' . ucfirst $name;
560 2         9 my %tagInfo = ( Name => $name );
561 2 50       9 $tagInfo{Groups} = { 2 => 'Time' } if $tag=~/Date$/;
562 2         17 $et->VPrint(0, " [adding $tag]\n");
563 2         10 AddTagToTable($tagTablePtr, $tag, \%tagInfo);
564             }
565 8 100       33 if ($val =~ /^bplist0/) {
566 4         18 my %dirInfo = ( DataPt => \$val );
567 4         845 require Image::ExifTool::PLIST;
568 4 50       22 if (Image::ExifTool::PLIST::ProcessBinaryPLIST($et, \%dirInfo, $tagTablePtr)) {
569 4 50       37 return undef if ref $dirInfo{Value} eq 'HASH';
570             $val = $dirInfo{Value}
571 4         14 } else {
572 0         0 $et->Warn("Error decoding $$tagTablePtr{$tag}{Name}");
573 0         0 return undef;
574             }
575             }
576 8 50 66     123 if (not ref $val and ($val =~ /\0/ or length($val) > 200) or $tag eq 'XAttrMDLabel') {
      66        
      66        
577 1         2 my $buff = $val;
578 1         3 $val = \$buff;
579             }
580 8         30 return $val;
581             }
582              
583             #------------------------------------------------------------------------------
584             # Read MacOS extended attribute tags using 'xattr' utility
585             # Inputs: 0) ExifTool object ref, 1) file name
586             sub ExtractXAttrTags($$)
587             {
588 0     0 0 0 local $_;
589 0         0 my ($et, $file) = @_;
590 0         0 my ($fn, $tag, $val, $warn);
591              
592 0         0 ($fn = $file) =~ s/([`"\$\\])/\\$1/g; # escape necessary characters
593 0         0 $et->VPrint(0, '(running xattr)');
594 0         0 my @xattr = `/usr/bin/xattr -lx "$fn" 2> /dev/null`; # get MacOS extended attributes
595 0 0 0     0 if ($? or not @xattr) {
596 0 0       0 $? and $et->Warn('Error running "xattr" to extract XAttr tags');
597 0         0 return;
598             }
599 0         0 my $tagTablePtr = GetTagTable('Image::ExifTool::MacOS::XAttr');
600 0         0 $$et{INDENT} .= '| ';
601 0         0 $et->VerboseDir('XAttr');
602 0         0 push @xattr, ''; # (for a list terminator)
603 0         0 foreach (@xattr) {
604 0         0 chomp;
605 0 0 0     0 if (s/^[\dA-Fa-f]{8}//) {
    0          
606 0 0       0 $tag or $warn = 1, next;
607 0         0 s/\|.*//;
608 0         0 tr/ //d;
609 0 0 0     0 (/[^\dA-Fa-f]/ or length($_) & 1) and $warn = 2, next;
610 0 0       0 $val = '' unless defined $val;
611 0         0 $val .= pack('H*', $_);
612 0         0 next;
613             } elsif ($tag and defined $val) {
614 0         0 $val = ReadXAttrValue($et, $tagTablePtr, $tag, $val);
615 0 0       0 $et->HandleTag($tagTablePtr, $tag, $val) if defined $val;
616 0         0 undef $tag;
617 0         0 undef $val;
618             }
619 0 0       0 next unless length;
620 0 0       0 s/:$// or $warn = 3, next; # attribute name must have trailing ":"
621 0 0       0 defined $val and $warn = 4, undef $val;
622             # remove random ID after kMDLabel in tag ID
623 0         0 ($tag = $_) =~ s/^com.apple.metadata:kMDLabel_.*/com.apple.metadata:kMDLabel/s;
624             }
625 0 0       0 $warn and $et->Warn(qq{Error $warn parsing "xattr" output});
626 0         0 $$et{INDENT} =~ s/\| $//;
627             }
628              
629             #------------------------------------------------------------------------------
630             # Extract MacOS file creation date/time
631             # Inputs: 0) ExifTool object ref, 1) file name
632             sub GetFileCreateDate($$)
633             {
634 0     0 0 0 local $_;
635 0         0 my ($et, $file) = @_;
636 0         0 my ($fn, $tag, $val, $tmp);
637              
638 0         0 ($fn = $file) =~ s/([`"\$\\])/\\$1/g; # escape necessary characters
639 0         0 $et->VPrint(0, '(running stat)');
640 0         0 my $time = `/usr/bin/stat -f '%SB' -t '%Y:%m:%d %H:%M:%S%z' "$fn" 2> /dev/null`;
641 0 0 0     0 if ($? or not $time or $time !~ s/([-+]\d{2})(\d{2})\s*$/$1:$2/) {
      0        
642 0         0 $et->Warn('Error running "stat" to extract FileCreateDate');
643 0         0 return;
644             }
645 0         0 $$et{SET_GROUP1} = 'MacOS';
646 0         0 $et->FoundTag(FileCreateDate => $time);
647 0         0 delete $$et{SET_GROUP1};
648             }
649              
650             #------------------------------------------------------------------------------
651             # Read ATTR metadata from "._" file
652             # Inputs: 0) ExifTool ref, 1) dirInfo ref, 2) tag table ref
653             # Return: 1 on success
654             # (ref https://www.swiftforensics.com/2018/11/the-dot-underscore-file-format.html)
655             sub ProcessATTR($$$)
656             {
657 1     1 0 4 my ($et, $dirInfo, $tagTablePtr) = @_;
658 1         4 my $dataPt = $$dirInfo{DataPt};
659 1         4 my $dataPos = $$dirInfo{DataPos};
660 1         4 my $dataLen = length $$dataPt;
661              
662 1 50 33     13 $dataLen >= 58 and $$dataPt =~ /^.{34}ATTR/s or $et->Warn('Invalid ATTR header'), return 0;
663 1         5 my $entries = Get32u($dataPt, 66);
664 1         6 $et->VerboseDir('ATTR', $entries);
665             # (Note: The RAF is not in $dirInfo because it would break RSRC reading --
666             # the RSCR block uses relative offsets, while the ATTR block uses absolute! grrr!)
667 1         2 my $raf = $$et{RAF};
668 1         3 my $pos = 70; # first entry is after ATTR header
669 1         2 my $i;
670 1         5 for ($i=0; $i<$entries; ++$i) {
671 8 50       21 $pos + 12 > $dataLen and $et->Warn('Truncated ATTR entry'), last;
672 8         32 my $off = Get32u($dataPt, $pos);
673 8         26 my $len = Get32u($dataPt, $pos + 4);
674 8         30 my $n = Get8u($dataPt, $pos + 10); # number of characters in tag name
675 8 50       27 $pos + 11 + $n > $dataLen and $et->Warn('Truncated ATTR name'), last;
676 8         19 $off -= $dataPos; # convert to relative offset (grrr!)
677 8 50 33     32 $off < 0 or $off > $dataLen and $et->Warn('Invalid ATTR offset'), last;
678 8         29 my $tag = substr($$dataPt, $pos + 11, $n);
679 8         70 $tag =~ s/\0+$//; # remove null terminator
680             # remove random ID after kMDLabel in tag ID
681 8         24 $tag =~ s/^com.apple.metadata:kMDLabel_.*/com.apple.metadata:kMDLabel/s;
682 8 50       39 $off + $len > $dataLen and $et->Warn('Truncated ATTR value'), last;
683 8         42 my $val = ReadXAttrValue($et, $tagTablePtr, $tag, substr($$dataPt, $off, $len));
684 8 50       74 $et->HandleTag($tagTablePtr, $tag, $val,
685             DataPt => $dataPt,
686             DataPos => $dataPos,
687             Start => $off,
688             Size => $len,
689             ) if defined $val;
690 8         37 $pos += (11 + $n + 3) & -4; # step to next entry (on even 4-byte boundary)
691             }
692 1         8 return 1;
693             }
694              
695             #------------------------------------------------------------------------------
696             # Read information from a MacOS "._" sidecar file
697             # Inputs: 0) ExifTool ref, 1) dirInfo ref
698             # Returns: 1 on success, 0 if this wasn't a valid "._" file
699             # (ref https://www.swiftforensics.com/2018/11/the-dot-underscore-file-format.html)
700             sub ProcessMacOS($$)
701             {
702 1     1 0 4 my ($et, $dirInfo) = @_;
703 1         4 my $raf = $$dirInfo{RAF};
704 1         13 my ($hdr, $buff, $i);
705              
706 1 50 33     7 return 0 unless $raf->Read($hdr, 26) == 26 and $hdr =~ /^\0\x05\x16\x07\0(.)\0\0Mac OS X /s;
707 1         5 my $ver = ord $1;
708             # (extension may be anything, so just echo back the incoming file extension if it exists)
709 1         10 $et->SetFileType(undef, undef, $$et{FILE_EXT});
710 1 50       5 $ver == 2 or $et->Warn("Unsupported file version $ver"), return 1;
711 1         7 SetByteOrder('MM');
712 1         4 my $tagTablePtr = GetTagTable('Image::ExifTool::MacOS::Main');
713 1         6 my $entries = Get16u(\$hdr, 0x18);
714 1         25 $et->VerboseDir('MacOS', $entries);
715 1 50       18 $raf->Read($hdr, $entries * 12) == $entries * 12 or $et->Warn('Truncated header'), return 1;
716 1         8 for ($i=0; $i<$entries; ++$i) {
717 2         5 my $pos = $i * 12;
718 2         11 my $tag = Get32u(\$hdr, $pos);
719 2         9 my $off = Get32u(\$hdr, $pos + 4);
720 2         9 my $len = Get32u(\$hdr, $pos + 8);
721 2 50       9 $len > 100000000 and $et->Warn('Record size too large'), last;
722 2 50 33     9 $raf->Seek($off,0) and $raf->Read($buff,$len) == $len or $et->Warn('Truncated record'), last;
723 2         36 $et->HandleTag($tagTablePtr, $tag, undef, DataPt => \$buff, DataPos => $off, Index => $i);
724             }
725 1         6 return 1;
726             }
727              
728             1; # end
729              
730             __END__