mirror of
https://github.com/blacktop/ipsw.git
synced 2026-05-08 12:22:26 +00:00
feat: add launchd command to stream plist metadata as JSONL
This commit is contained in:
@@ -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 <IPSW>",
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<filter>": "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
|
||||
}
|
||||
Reference in New Issue
Block a user