From fe32f0a8230f2be532cdabeaf8d89fc08b1c9985 Mon Sep 17 00:00:00 2001 From: blacktop Date: Wed, 6 May 2026 19:17:34 -0600 Subject: [PATCH] feat: add `launchd` command to stream plist metadata as JSONL --- cmd/ipsw/cmd/launchd.go | 81 ++++++ pkg/launchd/walk.go | 568 +++++++++++++++++++++++++++++++++++++++ pkg/launchd/walk_test.go | 337 +++++++++++++++++++++++ 3 files changed, 986 insertions(+) create mode 100644 cmd/ipsw/cmd/launchd.go create mode 100644 pkg/launchd/walk.go create mode 100644 pkg/launchd/walk_test.go diff --git a/cmd/ipsw/cmd/launchd.go b/cmd/ipsw/cmd/launchd.go new file mode 100644 index 000000000..7502e3a67 --- /dev/null +++ b/cmd/ipsw/cmd/launchd.go @@ -0,0 +1,81 @@ +/* +Copyright © 2018-2026 blacktop + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/apex/log" + "github.com/blacktop/ipsw/pkg/launchd" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + rootCmd.AddCommand(launchdCmd) + + launchdCmd.Flags().String("format", "jsonl", "Output format (only jsonl is supported)") + launchdCmd.Flags().String("pem-db", "", "AEA PEM DB JSON file path") + + viper.BindPFlag("launchd.format", launchdCmd.Flags().Lookup("format")) + viper.BindPFlag("launchd.pem-db", launchdCmd.Flags().Lookup("pem-db")) + + launchdCmd.MarkZshCompPositionalArgumentFile(1, "*.ipsw", "*.zip") +} + +var launchdCmd = &cobra.Command{ + Use: "launchd ", + Short: "Stream launchd and XPC plist metadata as JSONL", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + Hidden: true, + Example: heredoc.Doc(` + # Emit launchd/XPC metadata from an IPSW as deterministic JSONL + ❯ ipsw launchd iPhone18,1_26.0_23A5276f_Restore.ipsw --format jsonl`), + RunE: func(cmd *cobra.Command, args []string) error { + format := strings.ToLower(strings.TrimSpace(viper.GetString("launchd.format"))) + if format != "jsonl" { + return fmt.Errorf("unsupported --format %q (only \"jsonl\" is supported)", viper.GetString("launchd.format")) + } + + records, skipped, err := launchd.WalkIPSW(expandPath(args[0]), &launchd.IPSWConfig{ + PemDB: viper.GetString("launchd.pem-db"), + }) + for _, skip := range skipped { + log.Warnf("skipped %s volume: %v", skip.Volume, skip.Err) + } + if err != nil { + return err + } + + out, err := launchd.EncodeJSONL(records) + if err != nil { + return err + } + _, err = os.Stdout.Write(out) + return err + }, +} diff --git a/pkg/launchd/walk.go b/pkg/launchd/walk.go new file mode 100644 index 000000000..8d4cbd659 --- /dev/null +++ b/pkg/launchd/walk.go @@ -0,0 +1,568 @@ +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" +) + +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) { + var candidates []plistCandidate + seen := make(map[string]struct{}) + addCandidate := func(kind, path string) { + rel := relativePlistPath(root, 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(root, filepath.FromSlash(spec.dir), "*.plist")) + if err != nil { + return nil, err + } + for _, match := range matches { + addCandidate(spec.kind, match) + } + } + + if err := filepath.WalkDir(root, 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(root, 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(root, 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 +} diff --git a/pkg/launchd/walk_test.go b/pkg/launchd/walk_test.go new file mode 100644 index 000000000..c3f9fa61a --- /dev/null +++ b/pkg/launchd/walk_test.go @@ -0,0 +1,337 @@ +package launchd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/blacktop/go-plist" +) + +func TestWalkVolumeLaunchdMachServices(t *testing.T) { + root := t.TempDir() + writePlist(t, root, "/System/Library/LaunchDaemons/com.apple.test.plist", map[string]any{ + "Label": "com.apple.test", + "CFBundleIdentifier": "com.apple.test.bundle", + "ProgramArguments": []string{"/usr/libexec/testd", "--flag"}, + "MachServices": map[string]any{ + "com.apple.test.b": true, + "com.apple.test.a": map[string]any{"ResetAtClose": true}, + }, + "SandboxProfile": "testd", + "POSIXSpawnType": "Adaptive", + "RunAtLoad": true, + }, plist.XMLFormat) + + records, err := WalkVolume(root, "SystemOS") + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + record := records[0] + if record.SourceKind != SourceKindLaunchdDaemon { + t.Fatalf("source kind = %q", record.SourceKind) + } + if record.Label != "com.apple.test" { + t.Fatalf("label = %q", record.Label) + } + if record.BundleID != "com.apple.test.bundle" { + t.Fatalf("bundle id = %q", record.BundleID) + } + if record.Program != "/usr/libexec/testd" { + t.Fatalf("program = %q", record.Program) + } + if got, want := strings.Join(record.MachServices, ","), "com.apple.test.a,com.apple.test.b"; got != want { + t.Fatalf("mach services = %q, want %q", got, want) + } + if record.SandboxProfile != "testd" { + t.Fatalf("sandbox profile = %q", record.SandboxProfile) + } + if record.ServiceType != "Adaptive" { + t.Fatalf("service type = %q", record.ServiceType) + } + if record.Extra["RunAtLoad"] != true { + t.Fatalf("RunAtLoad missing from extra: %#v", record.Extra) + } + if _, ok := record.Extra["MachServices"]; ok { + t.Fatalf("captured MachServices leaked into extra: %#v", record.Extra) + } + if _, ok := record.Extra["CFBundleIdentifier"]; ok { + t.Fatalf("captured CFBundleIdentifier leaked into extra: %#v", record.Extra) + } +} + +func TestWalkVolumeXPCBundleLayouts(t *testing.T) { + root := t.TempDir() + writePlist(t, root, "/System/Library/PrivateFrameworks/Foo.framework/XPCServices/FooService.xpc/Info.plist", map[string]any{ + "CFBundleIdentifier": "com.apple.FooService", + "CFBundleExecutable": "FooService", + "XPCService": map[string]any{ + "ServiceType": "Application", + "SeatbeltProfiles": []string{"foo-sandbox"}, + "JoinExistingSession": true, + }, + }, plist.XMLFormat) + writePlist(t, root, "/System/Library/PrivateFrameworks/Bar.framework/XPCServices/BarService.xpc/Contents/Info.plist", map[string]any{ + "CFBundleIdentifier": "com.apple.BarService", + "CFBundleExecutable": "BarService", + "XPCService": map[string]any{ + "ServiceType": "Application", + }, + }, plist.XMLFormat) + + records, err := WalkVolume(root, "SystemOS") + if err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatalf("expected 2 records, got %d", len(records)) + } + + flat := findRecord(records, "/System/Library/PrivateFrameworks/Foo.framework/XPCServices/FooService.xpc/Info.plist") + if flat == nil { + t.Fatal("missing flat XPC record") + } + if flat.Program != "FooService" { + t.Fatalf("flat program = %q", flat.Program) + } + if got := strings.Join(flat.MachServices, ","); got != "com.apple.FooService" { + t.Fatalf("flat mach services = %q", got) + } + if flat.SandboxProfile != "foo-sandbox" { + t.Fatalf("flat sandbox profile = %q", flat.SandboxProfile) + } + if flat.ServiceType != "Application" { + t.Fatalf("flat service type = %q", flat.ServiceType) + } + xpcExtra, ok := flat.Extra["XPCService"].(map[string]any) + if !ok || xpcExtra["JoinExistingSession"] != true { + t.Fatalf("unexpected XPCService extra: %#v", flat.Extra["XPCService"]) + } + if _, ok := xpcExtra["ServiceType"]; ok { + t.Fatalf("captured ServiceType leaked into extra: %#v", xpcExtra) + } + + contents := findRecord(records, "/System/Library/PrivateFrameworks/Bar.framework/XPCServices/BarService.xpc/Contents/Info.plist") + if contents == nil { + t.Fatal("missing Contents XPC record") + } + if contents.Program != "Contents/MacOS/BarService" { + t.Fatalf("Contents program = %q", contents.Program) + } +} + +func TestWalkVolumeAppXPCAndPlainApp(t *testing.T) { + root := t.TempDir() + writePlist(t, root, "/Applications/HasService.app/Info.plist", map[string]any{ + "CFBundleIdentifier": "com.apple.HasService", + "CFBundleExecutable": "HasService", + "XPCService": map[string]any{ + "ServiceType": "Application", + }, + }, plist.XMLFormat) + writePlist(t, root, "/Applications/Plain.app/Info.plist", map[string]any{ + "CFBundleIdentifier": "com.apple.Plain", + "CFBundleExecutable": "Plain", + }, plist.XMLFormat) + + records, err := WalkVolume(root, "AppOS") + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("expected only the app with XPCService, got %d records", len(records)) + } + record := records[0] + if record.SourceKind != SourceKindAppXPC { + t.Fatalf("source kind = %q", record.SourceKind) + } + if record.Program != "HasService" { + t.Fatalf("program = %q", record.Program) + } +} + +func TestWalkVolumeMissingProgram(t *testing.T) { + root := t.TempDir() + writePlist(t, root, "/System/Library/LaunchAgents/com.apple.noprogram.plist", map[string]any{ + "Label": "com.apple.noprogram", + }, plist.XMLFormat) + + records, err := WalkVolume(root, "SystemOS") + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + if records[0].Program != "" { + t.Fatalf("program = %q", records[0].Program) + } +} + +func TestWalkVolumeProgramArgumentsKeepsEmptyFirstArgument(t *testing.T) { + root := t.TempDir() + writePlist(t, root, "/System/Library/LaunchDaemons/com.apple.emptyarg.plist", map[string]any{ + "Label": "com.apple.emptyarg", + "ProgramArguments": []any{"", "/usr/libexec/should-not-shift"}, + }, plist.XMLFormat) + + records, err := WalkVolume(root, "SystemOS") + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + if records[0].Program != "" { + t.Fatalf("program = %q, want empty ProgramArguments[0]", records[0].Program) + } +} + +func TestWalkVolumeParseErrorPassthrough(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "System/Library/LaunchAgents/bad.plist") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("not a plist"), 0644); err != nil { + t.Fatal(err) + } + + records, err := WalkVolume(root, "SystemOS") + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + record := records[0] + if record.SourceKind != SourceKindLaunchdAgent { + t.Fatalf("source kind = %q", record.SourceKind) + } + if record.PlistDigest == "" { + t.Fatal("parse error record has empty digest") + } + if record.Extra["parse_error"] == "" { + t.Fatalf("parse_error missing from extra: %#v", record.Extra) + } +} + +func TestEncodeJSONLDeterministic(t *testing.T) { + records := []Record{ + { + SourceKind: SourceKindXPCBundle, + PlistPath: "/b.plist", + Volume: "SystemOS", + PlistDigest: "2", + MachServices: []string{"z", "a"}, + SandboxProfile: "", + Extra: map[string]any{ + "array": []any{"b", "a"}, + "dict": map[string]any{"z": []any{"2", "1"}}, + "dsl": "kept literal", + }, + }, + { + SourceKind: SourceKindLaunchdDaemon, + PlistPath: "/a.plist", + Volume: "AppOS", + PlistDigest: "1", + Extra: map[string]any{}, + }, + } + + first, err := EncodeJSONL(records) + if err != nil { + t.Fatal(err) + } + second, err := EncodeJSONL(records) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatalf("encoding changed across runs:\n%s\n%s", first, second) + } + lines := bytes.Split(bytes.TrimSuffix(first, []byte("\n")), []byte("\n")) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + if !bytes.Contains(lines[0], []byte(`"volume":"AppOS"`)) { + t.Fatalf("records not sorted by volume/path: %s", first) + } + if !bytes.Contains(lines[0], []byte(`"mach_services":[]`)) { + t.Fatalf("empty mach services encoded as non-array: %s", lines[0]) + } + if !bytes.Contains(lines[1], []byte(`"mach_services":["a","z"]`)) { + t.Fatalf("mach services not sorted: %s", lines[1]) + } + if !bytes.Contains(lines[1], []byte(`"array":["a","b"]`)) { + t.Fatalf("extra array not sorted: %s", lines[1]) + } + if bytes.Contains(lines[1], []byte(`\u003c`)) || bytes.Contains(lines[1], []byte(`\u003e`)) { + t.Fatalf("HTML-sensitive characters were escaped: %s", lines[1]) + } + + var decoded map[string]any + if err := json.Unmarshal(lines[1], &decoded); err != nil { + t.Fatalf("invalid json: %v", err) + } +} + +func TestPlistDigestEquivalence(t *testing.T) { + doc := map[string]any{ + "Label": "com.apple.digest", + "RunAtLoad": true, + "MachServices": map[string]any{ + "com.apple.digest": true, + }, + } + xmlData, err := plist.Marshal(doc, plist.XMLFormat) + if err != nil { + t.Fatal(err) + } + binData, err := plist.Marshal(doc, plist.BinaryFormat) + if err != nil { + t.Fatal(err) + } + _, xmlDigest, err := parsePlist(xmlData) + if err != nil { + t.Fatal(err) + } + _, binDigest, err := parsePlist(binData) + if err != nil { + t.Fatal(err) + } + if xmlDigest != binDigest { + t.Fatalf("digest mismatch: xml=%s bin=%s", xmlDigest, binDigest) + } +} + +func writePlist(t *testing.T, root, rel string, doc map[string]any, format int) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(rel, "/"))) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + data, err := plist.Marshal(doc, format) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + +func findRecord(records []Record, path string) *Record { + for i := range records { + if records[i].PlistPath == path { + return &records[i] + } + } + return nil +}