Documentation
¶
Overview ¶
Package diskarbitration binds Apple's DiskArbitration framework: the macOS service that knows which block devices exist, which volume is mounted where, and how to take one away again politely.
What this package is for ¶
A program that manipulates disk images needs three answers macOS will not give it from the filesystem alone. Which /dev node did my image become? Where did the system mount it? How do I detach it without corrupting it? Today those answers are obtained by running hdiutil and diskutil and parsing their output — a subprocess, a plist parse, and a text format Apple never promised to keep. DiskArbitration is the service those tools themselves talk to.
The boundary — read this before adding anything ¶
THIS PACKAGE DECODES NO FILESYSTEM FORMAT. Not APFS, not HFS+, not FAT, not a partition map. It asks a macOS daemon what that daemon already believes and reports the answer. Description.VolumeKind is a label macOS handed over ("apfs", "hfs", "msdos"); nothing here has parsed a superblock to earn it.
Filesystem decoding lives in the go-filesystems org, behind github.com/go-filesystems/interface, and it is OS-independent by construction: eighteen drivers that read bytes and do not know what host they are on. The moment a superblock is parsed here, that independence is gone and one of those drivers has been forked into a macOS-only copy.
The Linux counterpart of THIS package — talking to a kernel that also already knows — is github.com/go-fsctl, in its own org, for the same reason. Two OS-specific packages that ask their own system, one OS-independent org that reads bytes. Keep them apart.
Shape of the API ¶
Everything hangs off a Session:
s, err := diskarbitration.Open()
if err != nil { return err }
defer s.Close()
for _, d := range must(s.DescribeAll()) {
fmt.Println(d)
}
Session.Disks enumerates the BSD names, Session.Describe answers with a Description, Session.Unmount and Session.Eject take a volume away, and Session.Watch delivers an Event each time a disk appears or disappears.
The run loop ¶
DiskArbitration is asynchronous: unmount, eject and the appearance callbacks are delivered on a CFRunLoop, and a process that never runs one never hears them. Open therefore starts one on a dedicated, thread-pinned goroutine — github.com/go-macos/objc's Run, not a loop written here — and Session.Close stops it. Reads (Session.Describe) are synchronous and do not depend on it, but they are served from the same session so a caller has one object to keep and one thing to close.
Callbacks are invoked ON that run-loop thread. A handler that blocks stops every other event, including the completion of an unmount somebody is waiting for. Hand the event to a channel and return.
Platforms ¶
macOS only. Every symbol exists on every platform and every operation reports ErrUnsupported elsewhere, so a consumer cross-compiles without build tags of its own. CGO_ENABLED=0 throughout: the framework is reached with purego, never cgo.
Index ¶
- Constants
- Variables
- func ValidName(name string) bool
- type Description
- type DiskError
- type Event
- type EventKind
- type Return
- type Session
- func (s *Session) Close() error
- func (s *Session) Describe(name string) (*Description, error)
- func (s *Session) DescribeAll() ([]*Description, error)
- func (s *Session) Disks() ([]string, error)
- func (s *Session) Eject(name string) error
- func (s *Session) Mounts() ([]*Description, error)
- func (s *Session) Unmount(name string, opts UnmountOptions) error
- func (s *Session) Watch(fn func(Event)) (*Watcher, error)
- type UnmountOptions
- type Watcher
Constants ¶
const ( KeyVolumePath = "DAVolumePath" KeyVolumeName = "DAVolumeName" KeyVolumeKind = "DAVolumeKind" KeyVolumeMountable = "DAVolumeMountable" KeyVolumeNetwork = "DAVolumeNetwork" KeyVolumeUUID = "DAVolumeUUID" KeyMediaBSDName = "DAMediaBSDName" KeyMediaName = "DAMediaName" KeyMediaSize = "DAMediaSize" KeyMediaBlockSize = "DAMediaBlockSize" KeyMediaWhole = "DAMediaWhole" KeyMediaLeaf = "DAMediaLeaf" KeyMediaRemovable = "DAMediaRemovable" KeyMediaEjectable = "DAMediaEjectable" KeyMediaWritable = "DAMediaWritable" KeyMediaEncrypted = "DAMediaEncrypted" KeyMediaContent = "DAMediaContent" KeyMediaPath = "DAMediaPath" KeyDeviceProtocol = "DADeviceProtocol" KeyDeviceModel = "DADeviceModel" KeyDeviceVendor = "DADeviceVendor" KeyDeviceRevision = "DADeviceRevision" KeyDeviceInternal = "DADeviceInternal" KeyDevicePath = "DADevicePath" KeyBusName = "DABusName" KeyBusPath = "DABusPath" KeyAppearanceTime = "DAAppearanceTime" )
The DADiskDescription keys, as the strings DiskArbitration actually uses.
Apple documents these as kDADiskDescription*Key, each an exported CFStringRef VARIABLE. Reading one means dereferencing an exported data pointer, which is exactly what go vet's unsafeptr check rejects — and the value it holds is the literal spelled here. CoreFoundation dictionaries hash and compare CFStrings by content, so a key built from the literal finds the same entry. This is the same trade github.com/go-macos/objc makes for kCFRunLoopDefaultMode, and the values below were read back off a live description dictionary rather than transcribed from a header.
const ( // ModelDiskImage is the DADeviceModel of an attached image. ModelDiskImage = "Disk Image" // ProtocolVirtualInterface is the DADeviceProtocol of an attached image // on current macOS. ProtocolVirtualInterface = "Virtual Interface" )
How macOS marks a device that is an attached disk image rather than hardware. This is the answer to "which of these is my DMG": a caller that attached an image and wants its /dev node keeps the descriptions Description.IsDiskImage accepts.
The two constants are BOTH needed, and which one carries the mark has moved. Measured on macOS 26.0 (arm64) against a freshly attached image: the protocol is "Virtual Interface" and it is the MODEL that says "Disk Image". Older releases — and much code written against them — put "Disk Image" in the protocol. Neither is checked alone here, because a binding that picked the wrong one would answer "no disk images attached" on half the fleet and give no hint why.
const Framework = "/System/Library/Frameworks/DiskArbitration.framework/DiskArbitration"
Framework is the DiskArbitration framework's path, opened by the darwin half on first use. It is exported because a caller that loads frameworks of its own has one list to keep, not two.
Variables ¶
var ( // ErrUnsupported is returned by every operation on non-darwin platforms. // DiskArbitration is macOS-only; the symbols exist everywhere so // consumers cross-compile without build tags of their own. ErrUnsupported = errors.New("diskarbitration: unsupported on this platform (darwin only)") // ErrNoSession reports that DASessionCreate returned NULL. It is not a // disk-level failure: the process could not reach the arbitration daemon // at all, and nothing else in this package will work until it can. ErrNoSession = errors.New("diskarbitration: DASessionCreate returned no session") // ErrClosed reports use of a [Session] after [Session.Close]. ErrClosed = errors.New("diskarbitration: session is closed") // ErrNoDisk reports that DADiskCreateFromBSDName returned NULL. // // It is RARE, and it does not mean "no such device". Creating a DADisk // reference is a local operation that does not consult the daemon: // measured on macOS 26.6.2, asking for "disk99" — a device that does not // exist — succeeds and yields a perfectly good reference. NULL here means // the framework could not allocate at all, or the session is unusable. // The absence of a device shows up as [ErrNoDescription]. ErrNoDisk = errors.New("diskarbitration: no such disk") // ErrNoDescription reports that DADiskCopyDescription returned NULL, // which is what "there is no such device" actually looks like — see // [ErrNoDisk] for why the two are the other way round from what the // names suggest. // // It is also what a caller sees when a disk vanishes BETWEEN the // enumeration and the description: a removable drive being unplugged, or // a disk image detaching underneath. [Session.DescribeAll] skips those // rather than failing the whole listing. ErrNoDescription = errors.New("diskarbitration: disk has no description (it may have gone away)") // ErrBadName reports a BSD name that is not of the form macOS uses for a // block device: diskN, diskNsM, or diskNsMsK for an APFS volume inside a // container. It is checked BEFORE the name reaches C, because the name // crosses as a NUL-terminated string and an embedded NUL would silently // truncate it into a different, existing device. ErrBadName = errors.New("diskarbitration: not a BSD disk name") )
Sentinel errors. They are stable and may be compared with errors.Is.
Functions ¶
func ValidName ¶
ValidName reports whether name is a macOS block-device name this package will speak about. Use it to check a name from a configuration file or a user before handing it to Session.Describe.
Types ¶
type Description ¶
type Description struct {
// BSDName is the device node's name without /dev, e.g. "disk3s1s1".
BSDName string
// MediaName is the IOKit media name: a product string for a whole disk,
// a partition label for a slice.
MediaName string
// MediaSize is the media's size in bytes.
MediaSize int64
// BlockSize is the media's block size in bytes.
BlockSize int64
// Whole reports a whole disk (diskN) rather than one of its slices.
Whole bool
// Leaf reports media with no further partition scheme below it.
Leaf bool
// Removable reports media that can be removed from its drive.
Removable bool
// Ejectable reports media macOS can eject — the precondition for
// [Session.Eject] meaning anything.
Ejectable bool
// Writable reports media that is not write-protected.
Writable bool
// Encrypted reports media macOS considers encrypted.
Encrypted bool
// Content is the partition's type hint: a GPT type GUID, or a scheme
// name such as "GUID_partition_scheme" on a whole disk.
Content string
// MediaPath is the IOKit registry path of the media object.
MediaPath string
// VolumePath is the mount point, or "" when nothing is mounted. It is
// THE answer to "where did my image end up".
VolumePath string
// VolumeName is the volume's name as the Finder shows it.
VolumeName string
// VolumeKind is the filesystem macOS believes is there ("apfs", "hfs",
// "msdos", …).
//
// It is a LABEL, not a decode: no superblock was parsed to produce it.
// Reading the filesystem is github.com/go-filesystems' job.
VolumeKind string
// VolumeMountable reports a volume macOS knows how to mount.
VolumeMountable bool
// VolumeNetwork reports a network volume rather than local media.
VolumeNetwork bool
// VolumeUUID is the volume's UUID, or "".
VolumeUUID string
// Protocol is the transport: "USB", "Apple Fabric", "PCI-Express", or
// [ProtocolDiskImage] for an attached image.
Protocol string
// Model, Vendor and Revision are the device's identification strings.
Model string
Vendor string
Revision string
// Internal reports a device built into the machine.
Internal bool
// DevicePath is the IOKit registry path of the device.
DevicePath string
// BusName and BusPath identify the bus the device hangs off.
BusName string
BusPath string
// Raw is the whole description dictionary, converted to Go values
// (string, bool, int64, []byte). It is kept because the typed fields
// above are a selection, and a caller that needs a key nobody
// anticipated should not have to fork this package to reach it.
Raw map[string]any
}
Description is what DiskArbitration says about one disk: a typed view of the description dictionary. Absent keys are the zero value — macOS omits a key rather than reporting an empty one, so "" for Description.VolumeName means "no volume here", not "a volume with no name".
Nothing in it was computed by reading the device. It is the daemon's opinion, which is the only opinion that matches what the rest of macOS will do.
func (*Description) Device ¶
func (d *Description) Device() string
Device is the full path of the block device node, e.g. "/dev/disk3s1s1". It is "" for a description with no BSD name, which is what a network volume looks like.
func (*Description) IsDiskImage ¶
func (d *Description) IsDiskImage() bool
IsDiskImage reports whether the device is backed by a disk image rather than hardware. See ModelDiskImage for why it accepts three spellings.
func (*Description) Mounted ¶
func (d *Description) Mounted() bool
Mounted reports whether the volume is mounted somewhere.
func (*Description) String ¶
func (d *Description) String() string
String renders one line: the node, what is on it and where it is mounted.
type DiskError ¶
type DiskError struct {
// Op is the operation that failed: "unmount" or "eject".
Op string
// Disk is the BSD name it was asked about.
Disk string
// Status is the DAReturn the dissenter carried.
Status Return
// Message is the daemon's own status string, or "" when it gave none.
Message string
}
DiskError is a failure DiskArbitration reported about one disk. It carries the dissenter's status and, when the daemon supplied one, its own sentence.
func (*DiskError) Advice ¶
Advice forwards Return.Advice for the status that was reported.
type Event ¶
type Event struct {
// Kind is [Appeared] or [Disappeared].
Kind EventKind
// BSDName is the device node's name, or "" for a disk that has none —
// which a mounted network volume does, and DiskArbitration reports it
// alongside the real ones.
BSDName string
// Description is the disk's description at the moment of the event, or
// nil for a [Disappeared] event (there is nothing left to describe).
Description *Description
}
Event is one disk appearing or disappearing.
type EventKind ¶
type EventKind int
EventKind is what happened to a disk.
const ( // Appeared means a disk became known to DiskArbitration. Registering a // watch replays one of these for every disk ALREADY present, so a // watcher never has to enumerate separately to catch up. Appeared EventKind = iota + 1 // Disappeared means a disk went away. Its description can no longer be // copied, so [Event.Description] is nil and only [Event.BSDName] // identifies it. Disappeared )
type Return ¶
type Return uint32
Return is a DAReturn: the status DiskArbitration reports for an operation it refused. The numeric values are Apple's, pinned here so a mis-ordered constant cannot turn "busy" into "not permitted" silently.
const ( ReturnSuccess Return = 0 ReturnError Return = 0xF8DA0001 ReturnBusy Return = 0xF8DA0002 ReturnBadArgument Return = 0xF8DA0003 ReturnExclusiveAccess Return = 0xF8DA0004 ReturnNoResources Return = 0xF8DA0005 ReturnNotFound Return = 0xF8DA0006 ReturnNotMounted Return = 0xF8DA0007 ReturnNotPermitted Return = 0xF8DA0008 ReturnNotPrivileged Return = 0xF8DA0009 ReturnNotReady Return = 0xF8DA000A ReturnNotWritable Return = 0xF8DA000B ReturnUnsupported Return = 0xF8DA000C )
The DAReturn values. kDAReturnSuccess is zero; the rest are err_local | err_local_diskarbitration | n, which is the 0xF8DA00nn family.
func (Return) Advice ¶
Advice is the sentence to show a person, or "" when the status speaks for itself. It exists because the statuses that actually happen — a busy volume above all — are fixable by the person at the keyboard, and none of them says so.
It answers for the errno spellings as well as the kDAReturn ones, because the daemon uses both for the same situation and a caller should not have to know which it got today.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session is a connection to the disk arbitration daemon plus the run loop its asynchronous answers are delivered on. Open one, keep it, close it.
Every method is safe to call from any goroutine. Closing is idempotent, and a method called on a closed session reports ErrClosed rather than reaching a released C object — which is the difference between an error and a crash in somebody else's stack frame.
func Open ¶
Open connects to the disk arbitration daemon and starts the run loop its callbacks need. The caller must Close the result.
It reports ErrUnsupported off darwin and ErrNoSession when the daemon cannot be reached — which is a real outcome in a sandbox that has not been granted the service, not a defensive branch.
func (*Session) Close ¶
Close unregisters every watch, stops the run loop and releases the session. It is idempotent and always reports nil, so it may be deferred without ceremony; the error is in the signature because a Closer that cannot fail today is still a Closer.
func (*Session) Describe ¶
func (s *Session) Describe(name string) (*Description, error)
Describe answers what DiskArbitration knows about one disk. The name is a BSD name without /dev ("disk3s1s1"); a leading /dev/ is accepted and trimmed, because that is the form a caller has after reading a mount table.
It reports ErrBadName for a name that is not a block device's, and ErrNoDescription both when there is no such device and when there was one a moment ago — DiskArbitration does not distinguish those, and neither does this.
func (*Session) DescribeAll ¶
func (s *Session) DescribeAll() ([]*Description, error)
DescribeAll describes every disk Session.Disks finds, in the same order.
A disk that goes away between the listing and its description is SKIPPED, not reported as an error. Enumerating a set of removable devices is inherently racy, and a caller asking "what is here" is better served by the four disks that answered than by an error about the fifth that left.
func (*Session) Disks ¶
Disks lists the BSD names of the block devices present, in device order (disk0, disk0s1, …, disk10). The names are what Session.Describe, Session.Unmount and Session.Eject take.
It enumerates by reading /dev rather than by asking DiskArbitration, and the reason is worth stating: DiskArbitration has no "list" call at all. The framework's own way to enumerate is to register an appearance callback and let the daemon replay one event per existing disk — which needs a run loop, a registration, and a guess at how long to wait before deciding the replay is over. The /dev scan is exact and immediate, and it was checked against the callback replay on a live machine: the same twenty devices, in both.
Session.Watch still uses the replay, because a watcher wants the events anyway.
func (*Session) Eject ¶
Eject ejects the named disk and blocks until the daemon answers.
Ejecting is not unmounting. The media must already be unmounted — eject a mounted volume and the daemon answers ReturnBusy — so the sequence for a disk image or a removable drive is Unmount with UnmountWhole, then Eject the whole disk.
func (*Session) Mounts ¶
func (s *Session) Mounts() ([]*Description, error)
Mounts returns the descriptions of the volumes that are currently mounted, which is the question a caller who just attached a disk image is actually asking.
func (*Session) Unmount ¶
func (s *Session) Unmount(name string, opts UnmountOptions) error
Unmount unmounts the volume on the named disk and blocks until the daemon answers. A refusal comes back as a *DiskError carrying the Return and the daemon's own sentence; ReturnBusy is the common one and Return.Advice says what to do about it.
Pass UnmountWhole to take down every volume of the whole disk — the right option before ejecting a multi-partition image — and UnmountForce only where losing another process's unwritten data is acceptable.
func (*Session) Watch ¶
Watch delivers an Event to fn each time a disk appears or disappears.
Registration REPLAYS an Appeared event for every disk already present, so a caller that wants "what is here, and then what changes" needs only this — no separate enumeration, and no window between the two in which a disk could slip through unseen.
fn is called ON THE RUN-LOOP THREAD. Everything else this session does asynchronously — the completion of an Session.Unmount, every other event — waits behind it. Send to a channel and return; do not do work there, and above all do not call back into this session.
type UnmountOptions ¶
type UnmountOptions uint32
UnmountOptions is a DADiskUnmountOptions bit set.
const ( // UnmountDefault unmounts the one volume, and fails with [ReturnBusy] if // anything still has it open. UnmountDefault UnmountOptions = 0 // UnmountWhole unmounts every volume of the whole disk the named device // belongs to. It is kDADiskUnmountOptionWhole, and it is what "detach // this image" means for an image with several partitions. UnmountWhole UnmountOptions = 0x00000001 // UnmountForce unmounts even though something has the volume open. // // It is not a stronger request; it is a DIFFERENT one. Open files are // forcibly closed and unwritten data belonging to another process may be // lost. Reach for it when a person has been told what it means. UnmountForce UnmountOptions = 0x00080000 )
func (UnmountOptions) String ¶
func (o UnmountOptions) String() string
String renders the options for an error message.
type Watcher ¶
type Watcher struct {
// contains filtered or unexported fields
}
Watcher is a live registration made by Session.Watch. Stop it when done; Session.Close stops any that are left.