Skip to content

Instantly share code, notes, and snippets.

@tempelmann
Last active October 7, 2020 17:22
Show Gist options
  • Save tempelmann/80efc2eb84f0171a96822290dee7d8d9 to your computer and use it in GitHub Desktop.
Save tempelmann/80efc2eb84f0171a96822290dee7d8d9 to your computer and use it in GitHub Desktop.
Determine APFS Volume Group relationships in macOS Catalina and Big Sur
//
// Sample code for determining all invididual mounted volumes, including volume groups.
// Tested on macOS 10.13.6, 10.15.6 and 10.16 (11.0) b6
//
// See https://stackoverflow.com/questions/63876549/
//
// Created by Thomas Tempelmann on 14 Sep 20 to 17 Sep 20 (yes, this took me several days to get right).
//
#import <IOKit/IOKitLib.h>
#import <DiskArbitration/DiskArbitration.h>
#import "AppDelegate.h"
#define NOT !
#if DEBUG && TARGET_CPU_X86 // https://www.cocoawithlove.com/2008/03/break-into-debugger.html
#define DebugBreak() { asm("int3;"); }
#else
#define DebugBreak()
#endif
@interface Volume : NSObject
@property (strong) NSURL *url;
@property (weak) Volume *groupMemberOf;
@property (strong) NSMutableSet<Volume*> *groupMembers;
@property (strong) NSUUID *groupID;
@property (strong) NSUUID *volUUID;
@property (strong) NSData *identifier;
@property (strong) NSString *roles;
@end
@implementation Volume
- (NSString *)description {
NSString *ext = @"";
if (self.roles) {
ext = [ext stringByAppendingFormat:@" <%@>", self.roles];
}
if (self.groupMemberOf) {
ext = [ext stringByAppendingFormat:@" (member of %@)", self.groupMemberOf.url.path];
}
if (self.groupMembers) {
ext = [ext stringByAppendingFormat:@" (members: %@)", [[self.groupMembers.allObjects valueForKeyPath:@"url.path"] componentsJoinedByString:@", "]];
}
return [NSString stringWithFormat:@"'%@' %@%@", self.volumeName, self.url.path, ext];
}
- (NSString *)volumeName {
NSString *name = nil;
[self.url getResourceValue:&name forKey:NSURLVolumeNameKey error:nil];
return name;
}
- (NSString *)folderName {
NSString *name = nil;
[self.url getResourceValue:&name forKey:NSURLNameKey error:nil];
return name;
}
@end
@interface AppDelegate()
@property NSArray<Volume*> *volumes;
@property NSMutableDictionary<NSUUID*,NSUUID*> *groupIDByVolUUID;
@property NSMutableDictionary<NSUUID*,NSString*> *rolesByVolUUID;
@property NSMutableDictionary<NSUUID*,NSURL*> *volURLByVolUUID;
@end
@implementation AppDelegate
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
[self updateVolumes];
[NSApp terminate:0];
}
- (Volume *)volumeWithURL:(NSURL *)url {
Volume *result = nil;
for (Volume *volume in self.volumes) {
if ([volume.url isEqual:url]) {
result = volume;
break;
}
}
return result;
}
- (Volume * _Nullable)volumeWithUUID:(NSUUID *)uuid {
Volume *result = nil;
for (Volume *volume in self.volumes) {
if ([volume.volUUID isEqual:uuid]) {
result = volume;
break;
}
}
return result;
}
- (Volume * _Nullable)findOrCreateVolumeWithURL:(NSURL *)url volID:(NSUUID* _Nullable)volID {
Volume *vol;
// If we already know the Volume then let's re-use the existing object
if (volID) {
vol = [self volumeWithUUID:volID];
} else {
vol = [self volumeWithURL:url];
}
if (vol == nil) {
// A newly detected volume
vol = Volume.new;
vol.url = url;
vol.volUUID = volID;
if (volID) {
vol.groupID = self.groupIDByVolUUID[volID];
vol.roles = self.rolesByVolUUID[volID];
}
}
return vol;
}
- (NSSet<NSUUID*>*) volIDsMatchingGroupID:(NSUUID*)groupID {
return [self.groupIDByVolUUID keysOfEntriesPassingTest:^BOOL(NSUUID * _Nonnull currVolID, NSUUID * _Nonnull currGroupID, BOOL * _Nonnull stop) {
return [groupID isEqual:currGroupID];
}];
}
- (void)updateVolumes {
@synchronized (self.volumes) {
self.groupIDByVolUUID = NSMutableDictionary.new;
self.rolesByVolUUID = NSMutableDictionary.new;
self.volURLByVolUUID = NSMutableDictionary.new;
NSMutableArray<Volume*> *newVolumes = [NSMutableArray arrayWithCapacity:self.volURLByVolUUID.count];
DASessionRef daSession = DASessionCreate (NULL);
// Get every mounted volume's volume UUID and volume group ID from the IO Registry
io_iterator_t iterator; io_object_t obj;
IOServiceGetMatchingServices (kIOMasterPortDefault, IOServiceMatching("IOMediaBSDClient"), &iterator);
while ((obj = IOIteratorNext (iterator)) != 0) {
io_object_t obj2;
IORegistryEntryGetParentEntry (obj, kIOServicePlane, &obj2);
DADiskRef daDisk = DADiskCreateFromIOMedia (NULL, daSession, obj2);
CFDictionaryRef daInfo = DADiskCopyDescription (daDisk);
NSURL *volURL = CFDictionaryGetValue (daInfo, CFSTR("DAVolumePath"));
CFUUIDRef uuid = CFDictionaryGetValue (daInfo, CFSTR("DAVolumeUUID"));
if (uuid) {
NSUUID *volID = [NSUUID.alloc initWithUUIDString:CFBridgingRelease(CFUUIDCreateString(NULL, uuid))];
NSArray *roles = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("Role"), kCFAllocatorDefault, 0));
NSString *groupID = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("VolGroupUUID"), kCFAllocatorDefault, 0));
if ([groupID isEqualToString:@"00000000-0000-0000-0000-000000000000"]) {
groupID = nil;
}
if (groupID) {
self.groupIDByVolUUID[volID] = [NSUUID.alloc initWithUUIDString:groupID];
}
self.volURLByVolUUID[volID] = volURL; // may be nil, but needed to identify the boot volume's role, see below
if (roles == nil && [volURL.path isEqualToString:@"/"]) {
// Since Big Sur, the startup system volume is mounted from a Snapshot that has no Role entry in the IO Registry
roles = @[@"System"];
}
self.rolesByVolUUID[volID] = [roles componentsJoinedByString:@","];
}
CFRelease (daInfo);
CFRelease (daDisk);
}
NSLog (@"\nAll volumes: %@\nAll groups: %@\nAll roles: %@", self.volURLByVolUUID, self.groupIDByVolUUID, self.rolesByVolUUID);
// This is the block handler for checking and adding a found volume
void (^addVolume)(NSUUID *volID, NSURL *volURL) = ^(NSUUID *volID, NSURL *volURL) {
if (volURL == nil) volURL = self.volURLByVolUUID[volID];
// Only collect browsable volumes
NSNumber *isBrowsable = @NO;
[volURL getResourceValue:&isBrowsable forKey:NSURLVolumeIsBrowsableKey error:nil];
if (NOT isBrowsable.boolValue) return;
// Prevent adding the same volume multiple times
NSString *volPath = volURL.path.decomposedStringWithCanonicalMapping;
NSInteger foundIdx = [newVolumes indexOfObjectPassingTest:^BOOL(Volume * _Nonnull vol, NSUInteger idx, BOOL * _Nonnull stop) {
// The isEqual test fails with volumes that contain Umlauts such as an "ü": return [vol.url isEqual:volURL];
return [vol.url.path.decomposedStringWithCanonicalMapping isEqualToString:volPath];
}];
if (foundIdx != NSNotFound) return;
// Create a new volume object and add it to out array
Volume *vol = [self findOrCreateVolumeWithURL:volURL volID:volID];
if (vol == nil) return;
[newVolumes addObject:vol];
// Identify any group-connected volumes
NSMutableArray<Volume*> *groupedVols = NSMutableArray.array;
[[self volIDsMatchingGroupID:vol.groupID] enumerateObjectsUsingBlock:^(NSUUID * _Nonnull volID, BOOL * _Nonnull stop) {
NSURL *url = self.volURLByVolUUID[volID];
if (url && NOT [url isEqual:vol.url]) {
Volume *vol = [self findOrCreateVolumeWithURL:url volID:volID];
if (vol) [groupedVols addObject:vol];
}
}];
for (Volume *member in groupedVols) {
member.groupMemberOf = vol;
if (vol.groupMembers == nil) vol.groupMembers = NSMutableSet.new;
[vol.groupMembers addObject:member];
if (NOT [vol.roles isEqualToString:@"System"] && (vol.roles != nil)) {
DebugBreak();
NSLog(@"Oops2 - unexpected volume grouping");
}
if (NOT [newVolumes containsObject:member]) {
[newVolumes addObject:member];
}
}
};
// Process all grouped volumes first
for (NSUUID *groupID in self.groupIDByVolUUID.allKeys) {
// Sort the vols so that "System" roles come first
NSArray<NSUUID*> *volIDsInGroup = [self volIDsMatchingGroupID:groupID].allObjects;
volIDsInGroup = [volIDsInGroup sortedArrayUsingComparator:^NSComparisonResult(NSUUID* _Nonnull id1, NSUUID* _Nonnull id2) {
NSString *role1 = self.rolesByVolUUID[id1], *role2 = self.rolesByVolUUID[id2];
if ([role1 isEqualToString:@"System"]) return -1;
if ([role2 isEqualToString:@"System"]) return 1;
return [role1 compare:role2];
}];
// By having the System vol added first, it'll become the root of the group, with all others
// in the group being added as group members of it.
[volIDsInGroup enumerateObjectsUsingBlock:^(NSUUID * _Nonnull volID, NSUInteger idx, BOOL * _Nonnull stop) {
addVolume (volID, nil);
}];
};
// Process all volumes with roles
[self.volURLByVolUUID enumerateKeysAndObjectsUsingBlock:^(NSUUID * _Nonnull volID, NSURL * _Nonnull volURL, BOOL * _Nonnull stop) {
addVolume (volID, volURL);
}];
// Process all visible volumes
NSArray<NSURL*> *currentVolURLs = [NSFileManager.defaultManager mountedVolumeURLsIncludingResourceValuesForKeys:nil options:NSVolumeEnumerationSkipHiddenVolumes];
[currentVolURLs enumerateObjectsUsingBlock:^(NSURL * _Nonnull volURL, NSUInteger idx, BOOL * _Nonnull stop) {
addVolume (nil, volURL);
}];
self.volumes = newVolumes;
CFRelease (daSession);
}
NSLog (@"\nNew volumes: %@\n", self.volumes);
}
@end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment