package installer import ( "bytes" "encoding/xml" "errors" "fmt" "io" "os" "path" "path/filepath" "strings" ) // dmgMount identifies one mounted filesystem reported by hdiutil attach. // Device and MountPoint always originate from the same system-entities entry. type dmgMount struct { Device string MountPoint string } // parseHDIUtilAttachPlist decodes the subset of Apple's property-list format // emitted by `hdiutil attach -plist`. A small local decoder keeps this security // boundary independent of a general-purpose plist dependency. func parseHDIUtilAttachPlist(data []byte) ([]dmgMount, error) { return parseHDIUtilMountPlist(data, true) } func parseHDIUtilMountPlist(data []byte, requireMounts bool) ([]dmgMount, error) { decoder := xml.NewDecoder(bytes.NewReader(data)) var root any for { token, err := decoder.Token() if err == io.EOF { break } if err != nil { return nil, fmt.Errorf("decode hdiutil plist: %w", err) } start, ok := token.(xml.StartElement) if !ok || start.Name.Local == "plist" { continue } root, err = decodePlistValue(decoder, start) if err != nil { return nil, fmt.Errorf("decode hdiutil plist: %w", err) } break } if _, ok := root.(map[string]any); !ok { return nil, fmt.Errorf("hdiutil plist does not contain a root dictionary") } entities := collectSystemEntities(root) if len(entities) == 0 && requireMounts { return nil, fmt.Errorf("hdiutil plist has no system-entities array") } mounts := make([]dmgMount, 0, len(entities)) for _, rawEntity := range entities { entity, ok := rawEntity.(map[string]any) if !ok { continue } device, _ := entity["dev-entry"].(string) mountPoint, _ := entity["mount-point"].(string) if device != "" && mountPoint != "" { mounts = append(mounts, dmgMount{Device: device, MountPoint: mountPoint}) } } if len(mounts) == 0 && requireMounts { return nil, fmt.Errorf("hdiutil plist contains no mounted filesystem") } return mounts, nil } func collectSystemEntities(value any) []any { var entities []any switch typed := value.(type) { case map[string]any: for key, child := range typed { if key == "system-entities" { if array, ok := child.([]any); ok { entities = append(entities, array...) } continue } entities = append(entities, collectSystemEntities(child)...) } case []any: for _, child := range typed { entities = append(entities, collectSystemEntities(child)...) } } return entities } func decodePlistValue(decoder *xml.Decoder, start xml.StartElement) (any, error) { switch start.Name.Local { case "dict": result := make(map[string]any) var key string for { token, err := decoder.Token() if err != nil { return nil, err } switch typed := token.(type) { case xml.EndElement: if typed.Name.Local == start.Name.Local { if key != "" { return nil, fmt.Errorf("dictionary key %q has no value", key) } return result, nil } case xml.StartElement: if typed.Name.Local == "key" { if key != "" { return nil, fmt.Errorf("dictionary key %q has no value", key) } if err := decoder.DecodeElement(&key, &typed); err != nil { return nil, err } continue } if key == "" { return nil, fmt.Errorf("dictionary value has no key") } value, err := decodePlistValue(decoder, typed) if err != nil { return nil, err } result[key] = value key = "" } } case "array": var result []any for { token, err := decoder.Token() if err != nil { return nil, err } switch typed := token.(type) { case xml.EndElement: if typed.Name.Local == start.Name.Local { return result, nil } case xml.StartElement: value, err := decodePlistValue(decoder, typed) if err != nil { return nil, err } result = append(result, value) } } case "true": if err := decoder.Skip(); err != nil { return nil, err } return true, nil case "false": if err := decoder.Skip(); err != nil { return nil, err } return false, nil default: var value string if err := decoder.DecodeElement(&value, &start); err != nil { return nil, err } return value, nil } } func selectIINAMount(mountRoot string, mounts []dmgMount) (dmgMount, error) { root := filepath.Clean(mountRoot) var matches []dmgMount for _, mount := range mounts { if !strings.HasPrefix(mount.Device, "/dev/disk") || path.Clean(mount.Device) != mount.Device { return dmgMount{}, fmt.Errorf("hdiutil returned invalid device %q", mount.Device) } mountPoint := filepath.Clean(mount.MountPoint) if !pathIsWithinRoot(root, mountPoint) { return dmgMount{}, fmt.Errorf("hdiutil mounted outside the owned root: %q", mount.MountPoint) } info, err := os.Lstat(mountPoint) if err != nil { return dmgMount{}, fmt.Errorf("inspect returned IINA mount %q: %w", mountPoint, err) } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return dmgMount{}, fmt.Errorf("returned IINA mount is not a real directory: %q", mountPoint) } appInfo, err := os.Lstat(filepath.Join(mountPoint, "IINA.app")) if os.IsNotExist(err) { continue } if err != nil { return dmgMount{}, fmt.Errorf("inspect IINA.app on returned mount: %w", err) } if !appInfo.IsDir() || appInfo.Mode()&os.ModeSymlink != 0 { return dmgMount{}, fmt.Errorf("IINA.app on returned mount is not a real directory") } mount.MountPoint = mountPoint matches = append(matches, mount) } if len(matches) == 0 { return dmgMount{}, fmt.Errorf("IINA.app not found on any filesystem mounted from the verified DMG") } if len(matches) != 1 { return dmgMount{}, fmt.Errorf("verified DMG mounted multiple IINA.app bundles") } return matches[0], nil } func pathIsWithinRoot(root, candidate string) bool { relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(candidate)) return err == nil && relative != "." && filepath.IsLocal(relative) } // dmgCleanupError means an owned mount may still exist. Its staging directory // must be retained rather than recursively removed through a mounted image. type dmgCleanupError struct{ err error } func (e *dmgCleanupError) Error() string { return "IINA mount cleanup failed; staging retained: " + e.err.Error() } func (e *dmgCleanupError) Unwrap() error { return e.err } // withOwnedDMGMounts registers cleanup before attachment can have side effects. // Discovery uses an independent cleanup context supplied by its caller, so an // interrupted attach that emitted no complete plist still relinquishes mounts. func withOwnedDMGMounts(root string, attach func() ([]dmgMount, error), discover func() ([]dmgMount, error), detach func(string) error, use func([]dmgMount) error) (resultErr error) { var mounts []dmgMount defer func() { discovered, err := discover() mounts = append(mounts, discovered...) var cleanupErr error cleanupErr = errors.Join(cleanupErr, err) detached := make(map[string]bool) for index := len(mounts) - 1; index >= 0; index-- { mount := mounts[index] if !pathIsWithinRoot(root, mount.MountPoint) { continue } if !strings.HasPrefix(mount.Device, "/dev/disk") || path.Clean(mount.Device) != mount.Device { cleanupErr = errors.Join(cleanupErr, fmt.Errorf("invalid owned mount device %q", mount.Device)) continue } if detached[mount.Device] { continue } detached[mount.Device] = true cleanupErr = errors.Join(cleanupErr, detach(mount.Device)) } if cleanupErr != nil { resultErr = errors.Join(resultErr, &dmgCleanupError{err: cleanupErr}) } }() var err error mounts, err = attach() if err != nil { return err } return use(mounts) }