mirror of
https://github.com/blacktop/ipsw.git
synced 2026-06-07 12:27:36 +00:00
572 lines
14 KiB
Go
572 lines
14 KiB
Go
package launchd
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/blacktop/go-plist"
|
|
"github.com/blacktop/ipsw/internal/commands/mount"
|
|
"github.com/blacktop/ipsw/internal/utils"
|
|
)
|
|
|
|
const (
|
|
SourceKindLaunchdDaemon = "launchd_daemon"
|
|
SourceKindLaunchdAgent = "launchd_agent"
|
|
SourceKindNanoLaunchdDaemon = "nano_launchd_daemon"
|
|
SourceKindXPCBundle = "xpc_bundle"
|
|
SourceKindAppXPC = "app_xpc"
|
|
)
|
|
|
|
type ipswVolume struct {
|
|
name string
|
|
typ string
|
|
}
|
|
|
|
type launchdPlistDir struct {
|
|
kind string
|
|
dir string
|
|
}
|
|
|
|
var ipswFilesystemVolumes = []ipswVolume{
|
|
{name: "AppOS", typ: "app"},
|
|
{name: "FileSystem", typ: "fs"},
|
|
{name: "SystemOS", typ: "sys"},
|
|
{name: "ExclaveOS", typ: "exc"},
|
|
}
|
|
|
|
var launchdPlistDirs = []launchdPlistDir{
|
|
{kind: SourceKindLaunchdDaemon, dir: "/System/Library/LaunchDaemons"},
|
|
{kind: SourceKindLaunchdAgent, dir: "/System/Library/LaunchAgents"},
|
|
{kind: SourceKindNanoLaunchdDaemon, dir: "/System/Library/NanoLaunchDaemons"},
|
|
}
|
|
|
|
var launchdCapturedTopLevelKeys = map[string]bool{
|
|
"Label": true,
|
|
"Program": true,
|
|
"ProgramArguments": true,
|
|
"MachServices": true,
|
|
"SandboxProfile": true,
|
|
"POSIXSpawnType": true,
|
|
"ProcessType": true,
|
|
"CFBundleIdentifier": true,
|
|
}
|
|
|
|
var bundleCapturedTopLevelKeys = map[string]bool{
|
|
"CFBundleIdentifier": true,
|
|
"CFBundleExecutable": true,
|
|
"Program": true,
|
|
"ProgramArguments": true,
|
|
"SeatbeltProfiles": true,
|
|
"POSIXSpawnType": true,
|
|
"ProcessType": true,
|
|
}
|
|
|
|
type Record struct {
|
|
SourceKind string `json:"source_kind"`
|
|
PlistPath string `json:"plist_path"`
|
|
Volume string `json:"volume"`
|
|
PlistDigest string `json:"plist_digest"`
|
|
Label string `json:"label"`
|
|
Program string `json:"program"`
|
|
BundleID string `json:"bundle_id"`
|
|
MachServices []string `json:"mach_services"`
|
|
SandboxProfile string `json:"sandbox_profile"`
|
|
ServiceType string `json:"service_type"`
|
|
Extra map[string]any `json:"extra"`
|
|
}
|
|
|
|
type IPSWConfig struct {
|
|
PemDB string
|
|
}
|
|
|
|
type SkippedVolume struct {
|
|
Volume string
|
|
Type string
|
|
Err error
|
|
}
|
|
|
|
func (s SkippedVolume) Error() string {
|
|
if s.Err == nil {
|
|
return fmt.Sprintf("%s: skipped", s.Volume)
|
|
}
|
|
return fmt.Sprintf("%s: %v", s.Volume, s.Err)
|
|
}
|
|
|
|
func WalkIPSW(path string, cfg *IPSWConfig) ([]Record, []SkippedVolume, error) {
|
|
if cfg == nil {
|
|
cfg = &IPSWConfig{}
|
|
}
|
|
|
|
var records []Record
|
|
var skipped []SkippedVolume
|
|
mounted := 0
|
|
seenDMGs := make(map[string]struct{})
|
|
|
|
for _, vol := range ipswFilesystemVolumes {
|
|
ctx, err := mount.DmgInIPSW(path, vol.typ, &mount.Config{PemDB: cfg.PemDB})
|
|
if err != nil {
|
|
skipped = append(skipped, SkippedVolume{Volume: vol.name, Type: vol.typ, Err: err})
|
|
continue
|
|
}
|
|
|
|
dmgPath := filepath.Clean(ctx.DmgPath)
|
|
if _, seen := seenDMGs[dmgPath]; seen {
|
|
if !ctx.AlreadyMounted {
|
|
if err := ctx.Unmount(); err != nil {
|
|
skipped = append(skipped, SkippedVolume{Volume: vol.name, Type: vol.typ, Err: fmt.Errorf("unmount failed: %w", err)})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
seenDMGs[dmgPath] = struct{}{}
|
|
mounted++
|
|
|
|
volumeRecords, walkErr := WalkVolume(ctx.MountPoint, vol.name)
|
|
var unmountErr error
|
|
if !ctx.AlreadyMounted {
|
|
unmountErr = ctx.Unmount()
|
|
}
|
|
if walkErr != nil {
|
|
skipped = append(skipped, SkippedVolume{Volume: vol.name, Type: vol.typ, Err: walkErr})
|
|
if unmountErr != nil {
|
|
skipped = append(skipped, SkippedVolume{Volume: vol.name, Type: vol.typ, Err: fmt.Errorf("unmount failed: %w", unmountErr)})
|
|
}
|
|
continue
|
|
}
|
|
records = append(records, volumeRecords...)
|
|
if unmountErr != nil {
|
|
skipped = append(skipped, SkippedVolume{Volume: vol.name, Type: vol.typ, Err: fmt.Errorf("unmount failed: %w", unmountErr)})
|
|
}
|
|
}
|
|
|
|
if mounted == 0 {
|
|
return nil, skipped, errors.New("no IPSW filesystem DMGs mounted successfully")
|
|
}
|
|
SortRecords(records)
|
|
return records, skipped, nil
|
|
}
|
|
|
|
func WalkVolume(root, volume string) ([]Record, error) {
|
|
volumeRoot := utils.MountedFilesystemRoot(root)
|
|
|
|
var candidates []plistCandidate
|
|
seen := make(map[string]struct{})
|
|
addCandidate := func(kind, path string) {
|
|
rel := relativePlistPath(volumeRoot, path)
|
|
key := kind + "\x00" + rel
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
candidates = append(candidates, plistCandidate{kind: kind, path: path})
|
|
}
|
|
|
|
for _, spec := range launchdPlistDirs {
|
|
matches, err := filepath.Glob(filepath.Join(volumeRoot, filepath.FromSlash(spec.dir), "*.plist"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, match := range matches {
|
|
addCandidate(spec.kind, match)
|
|
}
|
|
}
|
|
|
|
if err := filepath.WalkDir(volumeRoot, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
if os.IsPermission(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if d.IsDir() || d.Name() != "Info.plist" {
|
|
return nil
|
|
}
|
|
rel := relativePlistPath(volumeRoot, path)
|
|
switch {
|
|
case isXPCInfoPlist(rel):
|
|
addCandidate(SourceKindXPCBundle, path)
|
|
case strings.HasSuffix(rel, ".app/Info.plist"):
|
|
addCandidate(SourceKindAppXPC, path)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
if candidates[i].path != candidates[j].path {
|
|
return candidates[i].path < candidates[j].path
|
|
}
|
|
return candidates[i].kind < candidates[j].kind
|
|
})
|
|
|
|
records := make([]Record, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
record, ok := recordFromFile(volumeRoot, volume, candidate.kind, candidate.path)
|
|
if ok {
|
|
records = append(records, record)
|
|
}
|
|
}
|
|
SortRecords(records)
|
|
return records, nil
|
|
}
|
|
|
|
func EncodeJSONL(records []Record) ([]byte, error) {
|
|
SortRecords(records)
|
|
var buf bytes.Buffer
|
|
for _, record := range records {
|
|
line, err := MarshalRecord(record)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf.Write(line)
|
|
buf.WriteByte('\n')
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func SortRecords(records []Record) {
|
|
sort.SliceStable(records, func(i, j int) bool {
|
|
if records[i].Volume != records[j].Volume {
|
|
return records[i].Volume < records[j].Volume
|
|
}
|
|
if records[i].PlistPath != records[j].PlistPath {
|
|
return records[i].PlistPath < records[j].PlistPath
|
|
}
|
|
return records[i].SourceKind < records[j].SourceKind
|
|
})
|
|
}
|
|
|
|
func MarshalRecord(record Record) ([]byte, error) {
|
|
if record.Extra == nil {
|
|
record.Extra = map[string]any{}
|
|
}
|
|
top := map[string]any{
|
|
"source_kind": record.SourceKind,
|
|
"plist_path": record.PlistPath,
|
|
"volume": record.Volume,
|
|
"plist_digest": record.PlistDigest,
|
|
"label": record.Label,
|
|
"program": record.Program,
|
|
"bundle_id": record.BundleID,
|
|
"mach_services": sortedStringSlice(record.MachServices),
|
|
"sandbox_profile": record.SandboxProfile,
|
|
"service_type": record.ServiceType,
|
|
"extra": canonicalJSONValue(record.Extra),
|
|
}
|
|
return marshalCompactJSON(top)
|
|
}
|
|
|
|
type plistCandidate struct {
|
|
kind string
|
|
path string
|
|
}
|
|
|
|
func recordFromFile(root, volume, sourceKind, path string) (Record, bool) {
|
|
rel := relativePlistPath(root, path)
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return parseErrorRecord(sourceKind, rel, volume, "", err), true
|
|
}
|
|
digest := rawDigest(raw)
|
|
|
|
doc, canonicalDigest, err := parsePlist(raw)
|
|
if err != nil {
|
|
return parseErrorRecord(sourceKind, rel, volume, digest, err), true
|
|
}
|
|
digest = canonicalDigest
|
|
|
|
if sourceKind == SourceKindAppXPC && !hasXPCService(doc) {
|
|
return Record{}, false
|
|
}
|
|
|
|
record := Record{
|
|
SourceKind: sourceKind,
|
|
PlistPath: rel,
|
|
Volume: volume,
|
|
PlistDigest: digest,
|
|
Label: labelFor(sourceKind, doc),
|
|
BundleID: stringValue(doc["CFBundleIdentifier"]),
|
|
MachServices: machServicesFor(sourceKind, doc),
|
|
Program: programFor(sourceKind, rel, doc),
|
|
Extra: extraFor(sourceKind, doc),
|
|
}
|
|
record.SandboxProfile = sandboxProfileFor(sourceKind, doc)
|
|
record.ServiceType = serviceTypeFor(doc)
|
|
return record, true
|
|
}
|
|
|
|
func parseErrorRecord(sourceKind, relPath, volume, digest string, err error) Record {
|
|
return Record{
|
|
SourceKind: sourceKind,
|
|
PlistPath: relPath,
|
|
Volume: volume,
|
|
PlistDigest: digest,
|
|
MachServices: []string{},
|
|
Extra: map[string]any{"parse_error": err.Error()},
|
|
}
|
|
}
|
|
|
|
func parsePlist(raw []byte) (map[string]any, string, error) {
|
|
var doc map[string]any
|
|
if _, err := plist.Unmarshal(raw, &doc); err != nil {
|
|
return nil, "", err
|
|
}
|
|
canonical, err := plist.Marshal(doc, plist.XMLFormat)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
sum := sha256.Sum256(canonical)
|
|
return doc, hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func rawDigest(raw []byte) string {
|
|
sum := sha256.Sum256(raw)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func relativePlistPath(root, path string) string {
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
rel = path
|
|
}
|
|
return "/" + filepath.ToSlash(filepath.Clean(rel))
|
|
}
|
|
|
|
func isXPCInfoPlist(rel string) bool {
|
|
return strings.HasSuffix(rel, ".xpc/Info.plist") ||
|
|
strings.HasSuffix(rel, ".xpc/Contents/Info.plist")
|
|
}
|
|
|
|
func labelFor(sourceKind string, doc map[string]any) string {
|
|
if isLaunchdKind(sourceKind) {
|
|
return stringValue(doc["Label"])
|
|
}
|
|
return stringValue(doc["CFBundleIdentifier"])
|
|
}
|
|
|
|
func programFor(sourceKind, rel string, doc map[string]any) string {
|
|
if program := stringValue(doc["Program"]); program != "" {
|
|
return program
|
|
}
|
|
if args := stringSlice(doc["ProgramArguments"]); len(args) > 0 {
|
|
return args[0]
|
|
}
|
|
executable := stringValue(doc["CFBundleExecutable"])
|
|
if executable == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(executable, "/") {
|
|
return filepath.ToSlash(filepath.Clean(executable))
|
|
}
|
|
if sourceKind == SourceKindXPCBundle && strings.HasSuffix(rel, ".xpc/Contents/Info.plist") {
|
|
return filepath.ToSlash(filepath.Join("Contents", "MacOS", executable))
|
|
}
|
|
return executable
|
|
}
|
|
|
|
func machServicesFor(sourceKind string, doc map[string]any) []string {
|
|
if isLaunchdKind(sourceKind) {
|
|
if machServices, ok := mapValue(doc["MachServices"]); ok {
|
|
keys := make([]string, 0, len(machServices))
|
|
for key := range machServices {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
return []string{}
|
|
}
|
|
bundleID := stringValue(doc["CFBundleIdentifier"])
|
|
if bundleID == "" {
|
|
return []string{}
|
|
}
|
|
return []string{bundleID}
|
|
}
|
|
|
|
func sandboxProfileFor(sourceKind string, doc map[string]any) string {
|
|
if isLaunchdKind(sourceKind) {
|
|
return stringValue(doc["SandboxProfile"])
|
|
}
|
|
if profiles := stringSlice(doc["SeatbeltProfiles"]); len(profiles) > 0 {
|
|
return profiles[0]
|
|
}
|
|
if xpc, ok := mapValue(doc["XPCService"]); ok {
|
|
if profiles := stringSlice(xpc["SeatbeltProfiles"]); len(profiles) > 0 {
|
|
return profiles[0]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func serviceTypeFor(doc map[string]any) string {
|
|
if xpc, ok := mapValue(doc["XPCService"]); ok {
|
|
if serviceType := stringValue(xpc["ServiceType"]); serviceType != "" {
|
|
return serviceType
|
|
}
|
|
}
|
|
if spawnType := stringValue(doc["POSIXSpawnType"]); spawnType != "" {
|
|
return spawnType
|
|
}
|
|
return stringValue(doc["ProcessType"])
|
|
}
|
|
|
|
func extraFor(sourceKind string, doc map[string]any) map[string]any {
|
|
extra := make(map[string]any)
|
|
captured := capturedTopLevelKeys(sourceKind)
|
|
for key, value := range doc {
|
|
if captured[key] {
|
|
continue
|
|
}
|
|
if key == "XPCService" {
|
|
if xpcExtra, ok := xpcServiceExtra(value); ok {
|
|
extra[key] = xpcExtra
|
|
}
|
|
continue
|
|
}
|
|
extra[key] = value
|
|
}
|
|
return canonicalJSONValue(extra).(map[string]any)
|
|
}
|
|
|
|
func capturedTopLevelKeys(sourceKind string) map[string]bool {
|
|
if isLaunchdKind(sourceKind) {
|
|
return launchdCapturedTopLevelKeys
|
|
}
|
|
return bundleCapturedTopLevelKeys
|
|
}
|
|
|
|
func xpcServiceExtra(value any) (map[string]any, bool) {
|
|
xpc, ok := mapValue(value)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
out := make(map[string]any)
|
|
for key, val := range xpc {
|
|
if key == "ServiceType" || key == "SeatbeltProfiles" {
|
|
continue
|
|
}
|
|
out[key] = val
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, false
|
|
}
|
|
return canonicalJSONValue(out).(map[string]any), true
|
|
}
|
|
|
|
func hasXPCService(doc map[string]any) bool {
|
|
_, ok := mapValue(doc["XPCService"])
|
|
return ok
|
|
}
|
|
|
|
func isLaunchdKind(sourceKind string) bool {
|
|
switch sourceKind {
|
|
case SourceKindLaunchdDaemon, SourceKindLaunchdAgent, SourceKindNanoLaunchdDaemon:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func mapValue(v any) (map[string]any, bool) {
|
|
m, ok := v.(map[string]any)
|
|
return m, ok
|
|
}
|
|
|
|
func stringValue(v any) string {
|
|
s, _ := stringValueOK(v)
|
|
return s
|
|
}
|
|
|
|
func stringValueOK(v any) (string, bool) {
|
|
switch value := v.(type) {
|
|
case string:
|
|
return value, true
|
|
case fmt.Stringer:
|
|
return value.String(), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func stringSlice(v any) []string {
|
|
switch values := v.(type) {
|
|
case []string:
|
|
return append([]string(nil), values...)
|
|
case []any:
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if s, ok := stringValueOK(value); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func sortedStringSlice(values []string) []string {
|
|
out := append([]string{}, values...)
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func canonicalJSONValue(v any) any {
|
|
switch value := v.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(value))
|
|
for key, sub := range value {
|
|
out[key] = canonicalJSONValue(sub)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, 0, len(value))
|
|
for _, sub := range value {
|
|
out = append(out, canonicalJSONValue(sub))
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return canonicalJSONKey(out[i]) < canonicalJSONKey(out[j])
|
|
})
|
|
return out
|
|
case []string:
|
|
out := append([]string(nil), value...)
|
|
sort.Strings(out)
|
|
return out
|
|
case []byte:
|
|
return base64.StdEncoding.EncodeToString(value)
|
|
case time.Time:
|
|
return value.UTC().Format(time.RFC3339Nano)
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func canonicalJSONKey(v any) string {
|
|
data, err := marshalCompactJSON(v)
|
|
if err != nil {
|
|
return fmt.Sprintf("%T:%v", v, v)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func marshalCompactJSON(v any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
return nil, err
|
|
}
|
|
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
|
|
}
|