Files
ipsw/pkg/launchd/walk.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
}