diff --git a/cmd/symbols/apple_upload.go b/cmd/symbols/apple_upload.go new file mode 100644 index 00000000..caf9d162 --- /dev/null +++ b/cmd/symbols/apple_upload.go @@ -0,0 +1,208 @@ +package symbols + +import ( + "bytes" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/launchdarkly/ldcli/internal/symbols/apple" +) + +// appleSymbolsIDPrefix is the storage segment for Apple symbol maps. Each map is +// keyed by its build UUID: _sym/apple/id/.dsymmap. The backend derives the +// same key from the image_uuid the device reports for a crashing frame. +const appleSymbolsIDPrefix = "_sym/apple/id" + +// appleSymbolExt is appended to the build UUID to form the object name, so +// uploaded artifacts carry a human-recognizable file extension. It has no role +// in lookup (maps are keyed by UUID); the backend appends the same extension. +const appleSymbolExt = ".dsymmap" + +// appleSymbolMap is one architecture's compiled .dsymmap ready to upload. +type appleSymbolMap struct { + Key string + UUID string + Arch string + Data []byte +} + +// uploadAppleDSYMs discovers .dSYM bundles under path, compiles each contained +// architecture to a .dsymmap symbol map, and uploads one object per build UUID. +func uploadAppleDSYMs(apiKey, projectID, path, backendURL string) error { + images, err := findDSYMImages(path) + if err != nil { + return fmt.Errorf("failed to find dSYM files: %w", err) + } + if len(images) == 0 { + return fmt.Errorf("no .dSYM bundles found in %s, is this the correct path?", path) + } + + maps, err := buildAppleMaps(images) + if err != nil { + return err + } + if len(maps) == 0 { + return fmt.Errorf("no architectures found in the discovered dSYM files") + } + + keys := make([]string, len(maps)) + for i, m := range maps { + keys[i] = m.Key + } + + uploadURLs, err := getSymbolUploadUrls(apiKey, projectID, keys, backendURL) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %w", err) + } + // getSymbolUploadUrls returns one URL per requested key, in order; a short + // list would misalign the pairing below, so require an exact match. + if len(uploadURLs) != len(maps) { + return fmt.Errorf("expected %d upload URLs but received %d", len(maps), len(uploadURLs)) + } + + for i, m := range maps { + if err := uploadBytes(m.Data, uploadURLs[i], fmt.Sprintf("%s (%s)", m.UUID, m.Arch)); err != nil { + return fmt.Errorf("failed to upload symbol map for %s: %w", m.UUID, err) + } + } + + fmt.Println("Successfully uploaded all symbols") + return nil +} + +// buildAppleMaps compiles every architecture of every dSYM image into a .dsymmap, +// deduplicating by UUID (a universal binary and its per-arch slices can repeat). +func buildAppleMaps(images []string) ([]appleSymbolMap, error) { + var maps []appleSymbolMap + seen := make(map[string]bool) + + for _, image := range images { + arches, err := apple.BuildFromMachO(image) + if err != nil { + return nil, fmt.Errorf("failed to process %s: %w", image, err) + } + for _, a := range arches { + if seen[a.UUID] { + continue + } + seen[a.UUID] = true + + var buf bytes.Buffer + if err := a.Builder.Encode(&buf); err != nil { + return nil, fmt.Errorf("failed to encode symbol map for %s: %w", a.UUID, err) + } + arch := archLabel(a.CPUType) + maps = append(maps, appleSymbolMap{ + Key: appleKey(a.UUID), + UUID: a.UUID, + Arch: arch, + Data: buf.Bytes(), + }) + fmt.Printf("Built symbol map for %s (%s, %d bytes)\n", a.UUID, arch, buf.Len()) + } + } + return maps, nil +} + +func appleKey(uuid string) string { + return fmt.Sprintf("%s/%s%s", appleSymbolsIDPrefix, uuid, appleSymbolExt) +} + +// findDSYMImages resolves path to the DWARF Mach-O images to symbolicate. path +// may be a single .dSYM bundle, a directory tree containing .dSYM bundles, or a +// DWARF Mach-O file directly. +func findDSYMImages(path string) ([]string, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !info.IsDir() { + // A file is treated as a DWARF Mach-O image (e.g. the inner dSYM file). + return []string{path}, nil + } + + if strings.HasSuffix(path, ".dSYM") { + return dwarfImagesIn(path) + } + + var images []string + err = filepath.WalkDir(path, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() && strings.HasSuffix(d.Name(), ".dSYM") { + found, ferr := dwarfImagesIn(p) + if ferr != nil { + return ferr + } + images = append(images, found...) + return filepath.SkipDir + } + return nil + }) + if err != nil { + return nil, err + } + return images, nil +} + +// dwarfImagesIn returns the Mach-O images inside a .dSYM bundle's +// Contents/Resources/DWARF directory. +func dwarfImagesIn(bundle string) ([]string, error) { + dwarfDir := filepath.Join(bundle, "Contents", "Resources", "DWARF") + entries, err := os.ReadDir(dwarfDir) + if err != nil { + return nil, fmt.Errorf("dSYM %s has no DWARF resources: %w", bundle, err) + } + var images []string + for _, entry := range entries { + if !entry.IsDir() { + images = append(images, filepath.Join(dwarfDir, entry.Name())) + } + } + return images, nil +} + +// archLabel maps a mach cputype to a human label for logs (best-effort). +func archLabel(cpuType uint32) string { + switch cpuType { + case 0x0100000C: + return "arm64" + case 0x0200000C: + return "arm64_32" + case 0x01000007: + return "x86_64" + case 0x00000007: + return "i386" + case 0x0000000C: + return "arm" + default: + return fmt.Sprintf("cpu-0x%x", cpuType) + } +} + +func uploadBytes(data []byte, uploadURL, name string) error { + req, err := http.NewRequest("PUT", uploadURL, bytes.NewReader(data)) + if err != nil { + return err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload failed with status code: %d", resp.StatusCode) + } + + fmt.Printf("[LaunchDarkly] Uploaded symbol map %s\n", name) + return nil +} diff --git a/cmd/symbols/apple_upload_test.go b/cmd/symbols/apple_upload_test.go new file mode 100644 index 00000000..9bf6c3fd --- /dev/null +++ b/cmd/symbols/apple_upload_test.go @@ -0,0 +1,97 @@ +package symbols + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" +) + +// fixtureDSYM is the checked-in universal dSYM shared with the apple package's +// golden test. It contains demo::outer with demo::inner inlined. +const fixtureDSYM = "../../internal/symbols/apple/testdata/symbolsdemo.dSYM" + +var uuidHex = regexp.MustCompile(`^[0-9A-F]{32}$`) + +func TestAppleKey(t *testing.T) { + assert.Equal(t, "_sym/apple/id/ABC123.dsymmap", appleKey("ABC123")) +} + +func TestFindDSYMImages_Bundle(t *testing.T) { + images, err := findDSYMImages(fixtureDSYM) + require.NoError(t, err) + require.Len(t, images, 1, "the fixture bundle has one DWARF image") + assert.True(t, strings.HasSuffix(images[0], "Contents/Resources/DWARF/symbolsdemo")) + _, statErr := os.Stat(images[0]) + require.NoError(t, statErr) +} + +func TestFindDSYMImages_TreeWalk(t *testing.T) { + // Pointing at the parent directory should still discover the .dSYM bundle. + parent := filepath.Dir(fixtureDSYM) + images, err := findDSYMImages(parent) + require.NoError(t, err) + require.NotEmpty(t, images) + assert.True(t, strings.HasSuffix(images[0], "Contents/Resources/DWARF/symbolsdemo")) +} + +func TestFindDSYMImages_DirectFile(t *testing.T) { + image := filepath.Join(fixtureDSYM, "Contents", "Resources", "DWARF", "symbolsdemo") + images, err := findDSYMImages(image) + require.NoError(t, err) + assert.Equal(t, []string{image}, images) +} + +func TestFindDSYMImages_Missing(t *testing.T) { + _, err := findDSYMImages("testdata/nope") + require.Error(t, err) +} + +// TestBuildAppleMaps exercises the command's build/encode/dedupe step against +// the real fixture and confirms the produced bytes are valid, keyed .dsymmap maps. +func TestBuildAppleMaps(t *testing.T) { + image := filepath.Join(fixtureDSYM, "Contents", "Resources", "DWARF", "symbolsdemo") + maps, err := buildAppleMaps([]string{image}) + require.NoError(t, err) + require.Len(t, maps, 2, "universal fixture yields arm64 + x86_64 maps") + + keys := map[string]bool{} + for _, m := range maps { + assert.Regexp(t, uuidHex, m.UUID) + assert.Equal(t, appleKey(m.UUID), m.Key) + assert.True(t, strings.HasPrefix(m.Key, "_sym/apple/id/")) + assert.False(t, keys[m.Key], "each arch gets a distinct key") + keys[m.Key] = true + + // The uploaded bytes must decode as a valid .dsymmap the backend can read. + parsed, err := dsymmap.Open(m.Data) + require.NoError(t, err) + assert.Equal(t, strings.ToUpper(m.UUID), keySuffixHex(m.Key)) + _ = parsed + } +} + +// TestBuildAppleMaps_DedupesUUID ensures the same image passed twice does not +// produce duplicate uploads. +func TestBuildAppleMaps_DedupesUUID(t *testing.T) { + image := filepath.Join(fixtureDSYM, "Contents", "Resources", "DWARF", "symbolsdemo") + maps, err := buildAppleMaps([]string{image, image}) + require.NoError(t, err) + assert.Len(t, maps, 2, "duplicate images must be deduplicated by UUID") +} + +func TestArchLabel(t *testing.T) { + assert.Equal(t, "arm64", archLabel(0x0100000C)) + assert.Equal(t, "x86_64", archLabel(0x01000007)) + assert.Contains(t, archLabel(0xdeadbeef), "cpu-0x") +} + +func keySuffixHex(key string) string { + return strings.TrimSuffix(key[strings.LastIndex(key, "/")+1:], appleSymbolExt) +} diff --git a/cmd/symbols/generate.go b/cmd/symbols/generate.go new file mode 100644 index 00000000..544af7dd --- /dev/null +++ b/cmd/symbols/generate.go @@ -0,0 +1,188 @@ +package symbols + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/analytics" +) + +const ( + // outputFlag is "out" rather than "output" to avoid colliding with the + // global --output flag that selects the CLI output format. + outputFlag = "out" + + // defaultOutput is where generated symbol files are written when --out is + // omitted. + defaultOutput = "symbols" +) + +// NewGenerateCmd builds `symbols generate`, which runs the same symbol +// processing as `symbols upload` but writes the resulting files to a local +// folder instead of uploading them. The output folder mirrors the storage key +// layout the backend expects, so it can be inspected or uploaded later by other +// means. +func NewGenerateCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Args: validators.Validate(), + Use: "generate", + Short: "Generate symbol files to a local folder", + Long: "Generate symbol files (React Native sourcemaps, Android R8/ProGuard mappings, or Apple dSYMs) into a local folder instead of uploading them to LaunchDarkly. The folder mirrors the storage layout the symbolication backend expects.", + RunE: generateRunE(), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + tracker := analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ) + tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "symbols", + map[string]interface{}{ + "action": cmd.Name(), + })) + }, + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + initGenerateFlags(cmd) + + return cmd +} + +func generateRunE() func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + symbolType := canonicalizeSymbolType(viper.GetString(typeFlag)) + if !isSupportedType(symbolType) { + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM) + } + + path := viper.GetString(pathFlag) + outputDir := viper.GetString(outputFlag) + if outputDir == "" { + outputDir = defaultOutput + } + + fmt.Printf("Generating %s symbols from %s into %s\n", symbolType, path, outputDir) + + // Apple dSYMs are compiled into per-arch .dsymmap symbol maps keyed by build + // UUID, ignoring the version/symbols-id lanes. + if symbolType == typeAppleDSYM { + return generateAppleDSYMs(path, outputDir) + } + + return generateSymbolFiles(symbolType, path, outputDir) + } +} + +// generateAppleDSYMs compiles the discovered dSYM images to .dsymmap symbol maps +// and writes one file per build UUID under outputDir, using the same storage +// key (_sym/apple/id/.dsymmap) that `symbols upload` would use. +func generateAppleDSYMs(path, outputDir string) error { + images, err := findDSYMImages(path) + if err != nil { + return fmt.Errorf("failed to find dSYM files: %w", err) + } + if len(images) == 0 { + return fmt.Errorf("no .dSYM bundles found in %s, is this the correct path?", path) + } + + maps, err := buildAppleMaps(images) + if err != nil { + return err + } + if len(maps) == 0 { + return fmt.Errorf("no architectures found in the discovered dSYM files") + } + + for _, m := range maps { + if err := writeSymbolFile(outputDir, m.Key, m.Data); err != nil { + return fmt.Errorf("failed to write symbol map for %s: %w", m.UUID, err) + } + } + + fmt.Printf("Successfully generated %d symbol file(s) in %s\n", len(maps), outputDir) + return nil +} + +// generateSymbolFiles discovers React Native or Android artifacts and copies +// each one to outputDir under the same storage key `symbols upload` would use, +// so the generated folder matches what the backend expects. +func generateSymbolFiles(symbolType, path, outputDir string) error { + files, err := getAllSymbolFiles(path, symbolType) + if err != nil { + return fmt.Errorf("failed to find symbol files: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("no symbol files found in %s, is this the correct path?", path) + } + + symbolsID := viper.GetString(symbolsIdFlag) + appVersion := viper.GetString(appVersionFlag) + basePath := viper.GetString(basePathFlag) + symbolsIDPrefix := symbolsIDPrefixForType(symbolType) + + for _, file := range files { + fileSymbolsID := symbolsID + if fileSymbolsID == "" { + fileSymbolsID = symbolsIDForArtifact(file.Path) + } + key := getS3Key(symbolsIDPrefix, fileSymbolsID, appVersion, basePath, file.Name) + + data, err := os.ReadFile(file.Path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", file.Path, err) + } + if err := writeSymbolFile(outputDir, key, data); err != nil { + return fmt.Errorf("failed to write %s: %w", file.Name, err) + } + } + + fmt.Printf("Successfully generated %d symbol file(s) in %s\n", len(files), outputDir) + return nil +} + +// writeSymbolFile writes data to outputDir/key, creating parent directories as +// needed. key uses forward slashes (a storage key); filepath.FromSlash maps it +// to the host separator so nested keys become nested folders. +func writeSymbolFile(outputDir, key string, data []byte) error { + dest := filepath.Join(outputDir, filepath.FromSlash(key)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(dest, data, 0o644); err != nil { + return err + } + fmt.Printf("[LaunchDarkly] Wrote %s\n", dest) + return nil +} + +func initGenerateFlags(cmd *cobra.Command) { + cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to generate (supported: %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym)", typeReactNative, typeAndroid, typeAppleDSYM, typeAppleDSYM)) + _ = cmd.MarkFlagRequired(typeFlag) + _ = cmd.Flags().SetAnnotation(typeFlag, "required", []string{"true"}) + _ = viper.BindPFlag(typeFlag, cmd.Flags().Lookup(typeFlag)) + + cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the symbol files are") + _ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag)) + + cmd.Flags().String(outputFlag, defaultOutput, "The directory to write the generated symbol files to (default: symbols)") + _ = viper.BindPFlag(outputFlag, cmd.Flags().Lookup(outputFlag)) + + cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") + _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) + + cmd.Flags().String(symbolsIdFlag, "", "The symbols id (launchdarkly.symbols_id.htlhash) to key files by (Symbols Id Lane). If omitted, a *.symbolsid sidecar next to the bundle is used when present") + _ = viper.BindPFlag(symbolsIdFlag, cmd.Flags().Lookup(symbolsIdFlag)) + + cmd.Flags().String(basePathFlag, "", "An optional base path for the generated symbol files") + _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) +} diff --git a/cmd/symbols/symbols.go b/cmd/symbols/symbols.go index 6e353803..4342d66a 100644 --- a/cmd/symbols/symbols.go +++ b/cmd/symbols/symbols.go @@ -17,6 +17,7 @@ func NewSymbolsCmd(client resources.Client, analyticsTrackerFn analytics.Tracker } cmd.AddCommand(NewUploadCmd(client, analyticsTrackerFn)) + cmd.AddCommand(NewGenerateCmd(analyticsTrackerFn)) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd diff --git a/cmd/symbols/upload.go b/cmd/symbols/upload.go index a656a55a..0250df71 100644 --- a/cmd/symbols/upload.go +++ b/cmd/symbols/upload.go @@ -62,6 +62,11 @@ const ( // stack-trace retrace. typeAndroid = "android" + // typeAppleDSYM compiles Apple dSYM debug info into per-architecture .dsymmap + // symbol maps (keyed by build UUID) for iOS/macOS crash symbolication. It is + // the canonical value; see symbolTypeAliases for accepted synonyms. + typeAppleDSYM = "apple-dsym" + // getSymbolUrlsQuery uses the dedicated `get_symbol_upload_urls_ld` query // (separate from `sourcemaps upload`) so symbol uploads travel over the // symbol endpoint, which accepts larger, multi-segment uploads. @@ -104,7 +109,7 @@ func NewUploadCmd(client resources.Client, analyticsTrackerFn analytics.TrackerF Args: validators.Validate(), Use: "upload", Short: "Upload symbol files", - Long: "Upload symbol files (React Native sourcemaps or Android R8/ProGuard mappings) to LaunchDarkly for error monitoring", + Long: "Upload symbol files (React Native sourcemaps, Android R8/ProGuard mappings, or Apple dSYMs) to LaunchDarkly for error monitoring", RunE: runE(client), PersistentPreRun: func(cmd *cobra.Command, args []string) { tracker := analyticsTrackerFn( @@ -129,9 +134,9 @@ func NewUploadCmd(client resources.Client, analyticsTrackerFn analytics.TrackerF func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { - symbolType := viper.GetString(typeFlag) + symbolType := canonicalizeSymbolType(viper.GetString(typeFlag)) if !isSupportedType(symbolType) { - return fmt.Errorf("unsupported --type %q; supported types: %s, %s", symbolType, typeReactNative, typeAndroid) + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM) } projectKey := viper.GetString(cliflags.ProjectFlag) @@ -173,6 +178,13 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error backendUrl = defaultBackendUrl } + // Apple dSYMs take a dedicated path: they are compiled to per-arch .dsymmap + // symbol maps keyed by build UUID, ignoring the version/symbols-id lanes. + if symbolType == typeAppleDSYM { + fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path) + return uploadAppleDSYMs(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, path, backendUrl) + } + symbolsIDPrefix := symbolsIDPrefixForType(symbolType) fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path) @@ -229,8 +241,36 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } } +// symbolTypeAliases maps user-friendly synonyms to a canonical --type value. +// All Apple platforms share the single dSYM-based pipeline, so any Apple +// platform acronym resolves to apple-dsym. +var symbolTypeAliases = map[string]string{ + "apple": typeAppleDSYM, + "apple-dsym": typeAppleDSYM, + "dsym": typeAppleDSYM, + "ios": typeAppleDSYM, + "ipados": typeAppleDSYM, + "tvos": typeAppleDSYM, + "watchos": typeAppleDSYM, + "visionos": typeAppleDSYM, + "macos": typeAppleDSYM, + "osx": typeAppleDSYM, +} + +// canonicalizeSymbolType resolves a user-supplied --type to its canonical value. +// Matching is case-insensitive and understands platform synonyms (e.g. "ios", +// "macos" -> apple-dsym). Unknown values are returned lower-cased/trimmed so +// isSupportedType can reject them with a clear error. +func canonicalizeSymbolType(symbolType string) string { + s := strings.ToLower(strings.TrimSpace(symbolType)) + if canonical, ok := symbolTypeAliases[s]; ok { + return canonical + } + return s +} + func isSupportedType(symbolType string) bool { - return symbolType == typeReactNative || symbolType == typeAndroid + return symbolType == typeReactNative || symbolType == typeAndroid || symbolType == typeAppleDSYM } // symbolsIDPrefixForType picks the Symbols Id Lane storage segment for the symbol @@ -461,7 +501,7 @@ func uploadFile(filePath, uploadUrl, name string) error { } func initFlags(cmd *cobra.Command) { - cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to upload (supported: %s, %s)", typeReactNative, typeAndroid)) + cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to upload (supported: %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym)", typeReactNative, typeAndroid, typeAppleDSYM, typeAppleDSYM)) _ = cmd.MarkFlagRequired(typeFlag) _ = cmd.Flags().SetAnnotation(typeFlag, "required", []string{"true"}) _ = viper.BindPFlag(typeFlag, cmd.Flags().Lookup(typeFlag)) diff --git a/cmd/symbols/upload_test.go b/cmd/symbols/upload_test.go index 3bef3aed..9fc2b2ed 100644 --- a/cmd/symbols/upload_test.go +++ b/cmd/symbols/upload_test.go @@ -248,7 +248,7 @@ func TestSymbolsIDForArtifact(t *testing.T) { } func TestUnsupportedType(t *testing.T) { - viper.Set(typeFlag, "apple-dsym") + viper.Set(typeFlag, "flutter") defer viper.Set(typeFlag, "") client := resources.NewClient("") @@ -256,3 +256,34 @@ func TestUnsupportedType(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported --type") } + +func TestIsSupportedType(t *testing.T) { + assert.True(t, isSupportedType(typeReactNative)) + assert.True(t, isSupportedType(typeAndroid)) + assert.True(t, isSupportedType(typeAppleDSYM)) + assert.False(t, isSupportedType("flutter")) + assert.False(t, isSupportedType("")) +} + +func TestCanonicalizeSymbolType(t *testing.T) { + // Apple platform synonyms all resolve to apple-dsym. + for _, alias := range []string{"apple-dsym", "apple", "dsym", "ios", "ipados", "tvos", "watchos", "visionos", "macos", "osx"} { + assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType(alias), alias) + } + + // Case-insensitive and whitespace-tolerant. + assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType("iOS")) + assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType(" Apple-DSYM ")) + assert.Equal(t, typeReactNative, canonicalizeSymbolType("React-Native")) + + // Canonical values pass through; unknown values are lower-cased for rejection. + assert.Equal(t, typeReactNative, canonicalizeSymbolType(typeReactNative)) + assert.Equal(t, typeAndroid, canonicalizeSymbolType(typeAndroid)) + assert.Equal(t, "flutter", canonicalizeSymbolType("Flutter")) + assert.False(t, isSupportedType(canonicalizeSymbolType("flutter"))) + + // Every alias must map to a supported canonical type. + for alias, canonical := range symbolTypeAliases { + assert.True(t, isSupportedType(canonical), alias) + } +} diff --git a/go.mod b/go.mod index 46834766..f1d7e9c1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.24.3 require ( github.com/adrg/xdg v0.5.3 + github.com/blacktop/go-dwarf v1.0.14 + github.com/blacktop/go-macho v1.1.282 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v0.10.0 @@ -13,6 +15,7 @@ require ( github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/iancoleman/strcase v0.3.0 + github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f github.com/launchdarkly/api-client-go/v14 v14.0.0 github.com/launchdarkly/go-sdk-common/v3 v3.4.0 github.com/launchdarkly/go-server-sdk/v7 v7.13.4 diff --git a/go.sum b/go.sum index 46c6a16b..a9710213 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,10 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/blacktop/go-dwarf v1.0.14 h1:OjmzfSgg/qAKckn2tWFebcgKgJ7HOqCj7bS+CiE1lrY= +github.com/blacktop/go-dwarf v1.0.14/go.mod h1:4W2FKgSFYcZLDwnR7k+apv5i3nrau4NGl9N6VQ9DSTo= +github.com/blacktop/go-macho v1.1.282 h1:DW3HYz5zVCT6+Jlp1+hxauYvEgxqZsGMvu8/1j4+Ibg= +github.com/blacktop/go-macho v1.1.282/go.mod h1:Hc5E2Lvt/U1VT+jOxr1O5l/LNFJeMYK4eAmDfazTiGc= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -189,6 +193,8 @@ github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSAS github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/internal/symbols/apple/dsym.go b/internal/symbols/apple/dsym.go new file mode 100644 index 00000000..5870128e --- /dev/null +++ b/internal/symbols/apple/dsym.go @@ -0,0 +1,381 @@ +// Package apple converts an Apple dSYM (Mach-O + DWARF) into the compact, +// per-architecture .dsymmap symbol maps the backend consumes. +// +// It is the ldcli-side (encode) half of the Apple symbolication pipeline: it +// extracts each architecture's build UUID, walks the DWARF debug info to recover +// function ranges, line tables, and inlined-call chains, demangles Swift and C++ +// names, and normalizes every address to be image-relative (file_addr − +// __TEXT.vmaddr) so it matches the rel_offset the device reports at crash time. +// +// Library choices (pure-Go so ldcli cross-compiles cleanly): +// - github.com/blacktop/go-macho Mach-O + fat parsing, UUID, __TEXT vmaddr +// - github.com/blacktop/go-dwarf DWARF line table + DIE walk (via go-macho) +// - github.com/blacktop/go-macho/pkg/swift Swift demangling +// - github.com/ianlancetaylor/demangle C/C++/Objective-C++ demangling +package apple + +import ( + "encoding/hex" + "os" + "strings" + + "github.com/blacktop/go-dwarf" + "github.com/blacktop/go-macho" + "github.com/blacktop/go-macho/pkg/swift" + "github.com/ianlancetaylor/demangle" + e "github.com/pkg/errors" + + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" +) + +// Arch is one architecture's symbol map recovered from a dSYM image. +type Arch struct { + // UUID is the build UUID as 32 uppercase hex chars with no dashes — the + // symbols_id lookup key the backend stores maps under (_sym/apple/id/). + UUID string + CPUType uint32 + CPUSubtype uint32 + Builder *dsymmap.Builder +} + +// BuildFromMachO opens a dSYM's DWARF Mach-O image at path — thin or fat — +// and returns one symbol map per architecture. The caller encodes each +// Arch.Builder to its own .dsymmap keyed by Arch.UUID. +func BuildFromMachO(path string) ([]Arch, error) { + // NewFatFile/NewFile read sections lazily from the ReaderAt, so f must stay + // open through the DWARF walk below. + f, err := os.Open(path) + if err != nil { + return nil, e.Wrapf(err, "opening %s", path) + } + defer f.Close() + + if fat, ferr := macho.NewFatFile(f); ferr == nil { + out := make([]Arch, 0, len(fat.Arches)) + for i := range fat.Arches { + a, err := buildArch(fat.Arches[i].File) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, nil + } else if ferr != macho.ErrNotFat { + return nil, e.Wrapf(ferr, "reading fat header of %s", path) + } + + mf, err := macho.NewFile(f) + if err != nil { + return nil, e.Wrapf(err, "reading Mach-O %s", path) + } + a, err := buildArch(mf) + if err != nil { + return nil, err + } + return []Arch{a}, nil +} + +func buildArch(f *macho.File) (Arch, error) { + uuidCmd := f.UUID() + if uuidCmd == nil { + return Arch{}, e.New("dSYM image has no LC_UUID load command") + } + uuid := [16]byte(uuidCmd.UUID) + + d, err := f.DWARF() + if err != nil { + return Arch{}, e.Wrap(err, "reading DWARF") + } + + var textVM uint64 + if seg := f.Segment("__TEXT"); seg != nil { + textVM = seg.Addr + } + + b := &dsymmap.Builder{ + UUID: uuid, + TextVMAddr: textVM, + CPUType: uint32(f.CPU), + CPUSubtype: uint32(f.SubCPU), + } + if err := populate(d, textVM, b); err != nil { + return Arch{}, err + } + + return Arch{ + UUID: strings.ToUpper(hex.EncodeToString(uuid[:])), + CPUType: uint32(f.CPU), + CPUSubtype: uint32(f.SubCPU), + Builder: b, + }, nil +} + +// scope is one level of the DWARF DIE tree during the depth-first walk. fn is +// the nearest enclosing concrete function (so inlined_subroutine DIEs know which +// function to attach to); inlineDepth is how many inlined_subroutine ancestors +// deep we are (0 inside the physical function body). +type scope struct { + fn *dsymmap.Function + inlineDepth uint32 +} + +func populate(d *dwarf.Data, textVM uint64, b *dsymmap.Builder) error { + r := d.Reader() + var stack []scope + var funcs []*dsymmap.Function + var curFiles []*dwarf.LineFile + + top := func() scope { + if len(stack) == 0 { + return scope{} + } + return stack[len(stack)-1] + } + + for { + ent, err := r.Next() + if err != nil { + return e.Wrap(err, "walking DWARF") + } + if ent == nil { + break + } + // A Tag==0 entry terminates the current sibling list: pop one scope. + if ent.Tag == 0 { + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + continue + } + + push := top() // scope inherited by this entry's children unless changed below + + switch ent.Tag { + case dwarf.TagCompileUnit: + curFiles = filesForEntry(d, ent) + addLines(d, ent, textVM, b) + + case dwarf.TagSubprogram: + if fn := makeFunction(d, ent, textVM); fn != nil { + funcs = append(funcs, fn) + push.fn = fn + } + push.inlineDepth = 0 + + case dwarf.TagInlinedSubroutine: + depth := top().inlineDepth + 1 + if fn := top().fn; fn != nil { + fn.Inlines = append(fn.Inlines, makeInlines(d, ent, textVM, depth, curFiles)...) + } + push.inlineDepth = depth + } + + if ent.Children { + stack = append(stack, push) + } + } + + for _, fp := range funcs { + b.Funcs = append(b.Funcs, *fp) + } + return nil +} + +// makeFunction builds a single Function spanning a subprogram's PC ranges, or +// nil for a declaration / fully-inlined subprogram with no code of its own. +func makeFunction(d *dwarf.Data, ent *dwarf.Entry, textVM uint64) *dsymmap.Function { + lo, hi, ok := spanOf(d, ent, textVM) + if !ok { + return nil + } + return &dsymmap.Function{Start: lo, End: hi, Name: entryName(d, ent)} +} + +// makeInlines builds one Inline per PC range of an inlined_subroutine, all +// sharing the resolved callee name, call-site file:line, and nesting depth. +func makeInlines(d *dwarf.Data, ent *dwarf.Entry, textVM uint64, depth uint32, files []*dwarf.LineFile) []dsymmap.Inline { + ranges, err := d.Ranges(ent) + if err != nil || len(ranges) == 0 { + return nil + } + name := entryName(d, ent) + callFile, callLine := callSite(ent, files) + + out := make([]dsymmap.Inline, 0, len(ranges)) + for _, rng := range ranges { + start, end, ok := relRange(rng[0], rng[1], textVM) + if !ok { + continue + } + out = append(out, dsymmap.Inline{ + Start: start, + End: end, + Name: name, + CallFile: callFile, + CallLine: callLine, + Depth: depth, + }) + } + return out +} + +// spanOf returns the image-relative [low,high) covering all of an entry's PC +// ranges (a single span; hot/cold split functions collapse to their extent). +func spanOf(d *dwarf.Data, ent *dwarf.Entry, textVM uint64) (uint64, uint64, bool) { + ranges, err := d.Ranges(ent) + if err != nil || len(ranges) == 0 { + return 0, 0, false + } + lo, hi := ranges[0][0], ranges[0][1] + for _, rng := range ranges[1:] { + if rng[0] < lo { + lo = rng[0] + } + if rng[1] > hi { + hi = rng[1] + } + } + return relRange(lo, hi, textVM) +} + +// relRange rebases an absolute [start,end) file range to image-relative, +// dropping ranges that precede __TEXT or are empty. +func relRange(start, end, textVM uint64) (uint64, uint64, bool) { + if end <= start || start < textVM { + return 0, 0, false + } + return start - textVM, end - textVM, true +} + +// callSite resolves an inlined_subroutine's DW_AT_call_file/call_line to a +// source file path and line in the caller. +func callSite(ent *dwarf.Entry, files []*dwarf.LineFile) (string, uint32) { + var line uint32 + if v, ok := ent.Val(dwarf.AttrCallLine).(int64); ok { + line = uint32(v) + } + file := "" + if v, ok := ent.Val(dwarf.AttrCallFile).(int64); ok { + if idx := int(v); idx >= 0 && idx < len(files) && files[idx] != nil { + file = files[idx].Name + } + } + return file, line +} + +func addLines(d *dwarf.Data, cu *dwarf.Entry, textVM uint64, b *dsymmap.Builder) { + lr, err := d.LineReader(cu) + if err != nil || lr == nil { + return + } + var le dwarf.LineEntry + for { + if err := lr.Next(&le); err != nil { + break // io.EOF or malformed table: stop, keep what we have + } + if le.Address < textVM { + continue + } + rel := le.Address - textVM + if le.EndSequence { + // Mark the end of a code range so a lookup in the following gap + // resolves to no source location rather than the previous row. + b.Lines = append(b.Lines, dsymmap.LineRow{Addr: rel}) + continue + } + file := "" + if le.File != nil { + file = le.File.Name + } + b.Lines = append(b.Lines, dsymmap.LineRow{Addr: rel, File: file, Line: uint32(le.Line)}) + } +} + +func filesForEntry(d *dwarf.Data, cu *dwarf.Entry) []*dwarf.LineFile { + files, err := d.FilesForEntry(cu) + if err != nil { + return nil + } + return files +} + +// entryName resolves the best human-readable name for a subprogram or +// inlined_subroutine, following abstract_origin/specification references and +// demangling Swift/C++ linkage names. +func entryName(d *dwarf.Data, ent *dwarf.Entry) string { + name, _ := ent.Val(dwarf.AttrName).(string) + linkage, _ := ent.Val(dwarf.AttrLinkageName).(string) + if name == "" && linkage == "" { + if off, ok := refOffset(ent); ok { + name, linkage = resolveName(d, off, 0) + } + } + return bestName(name, linkage) +} + +func refOffset(ent *dwarf.Entry) (dwarf.Offset, bool) { + if off, ok := ent.Val(dwarf.AttrAbstractOrigin).(dwarf.Offset); ok { + return off, true + } + if off, ok := ent.Val(dwarf.AttrSpecification).(dwarf.Offset); ok { + return off, true + } + return 0, false +} + +func resolveName(d *dwarf.Data, off dwarf.Offset, depth int) (name, linkage string) { + if depth > 4 { + return "", "" + } + r := d.Reader() + r.Seek(off) + ent, err := r.Next() + if err != nil || ent == nil { + return "", "" + } + name, _ = ent.Val(dwarf.AttrName).(string) + linkage, _ = ent.Val(dwarf.AttrLinkageName).(string) + if name == "" && linkage == "" { + if next, ok := refOffset(ent); ok { + return resolveName(d, next, depth+1) + } + } + return name, linkage +} + +// bestName prefers a demangled linkage name (fuller: module/type-qualified) and +// falls back to the plain DW_AT_name, then the raw linkage, then a placeholder. +func bestName(name, linkage string) string { + if linkage != "" { + if swift.IsMangled(linkage) { + if s, err := swift.Demangle(linkage); err == nil && s != "" { + return s + } + } + if s, ok := demangleCpp(linkage); ok { + return s + } + } + if name != "" { + return name + } + if linkage != "" { + return linkage + } + return "" +} + +func demangleCpp(sym string) (string, bool) { + cand := sym + if strings.HasPrefix(cand, "__Z") { // Mach-O adds a leading underscore + cand = cand[1:] + } + if !strings.HasPrefix(cand, "_Z") { + return "", false + } + out := demangle.Filter(cand) + if out == "" || out == cand { + return "", false + } + return out, true +} diff --git a/internal/symbols/apple/dsym_test.go b/internal/symbols/apple/dsym_test.go new file mode 100644 index 00000000..bd42d2ca --- /dev/null +++ b/internal/symbols/apple/dsym_test.go @@ -0,0 +1,112 @@ +package apple + +import ( + "bytes" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" +) + +// fixtureDWARF is the DWARF Mach-O inside the checked-in universal .dSYM. +// Regenerate with testdata/build.sh (UUIDs change on rebuild; the test asserts +// structure, not specific UUID values). +const fixtureDWARF = "testdata/symbolsdemo.dSYM/Contents/Resources/DWARF/symbolsdemo" + +var uuidHex = regexp.MustCompile(`^[0-9A-F]{32}$`) + +func TestBuildFromMachO_UniversalUUIDs(t *testing.T) { + arches, err := BuildFromMachO(fixtureDWARF) + require.NoError(t, err) + require.Len(t, arches, 2, "fixture is a universal arm64+x86_64 binary") + + seen := map[string]bool{} + for _, a := range arches { + assert.Regexp(t, uuidHex, a.UUID, "UUID must be 32 uppercase hex chars, no dashes") + assert.NotEqual(t, strings.Repeat("0", 32), a.UUID, "UUID must not be all zero") + assert.False(t, seen[a.UUID], "each arch has a distinct UUID") + seen[a.UUID] = true + assert.NotZero(t, a.CPUType) + require.NotNil(t, a.Builder) + } +} + +// TestBuildFromMachO_SymbolicatesInlineChain runs the full Stage 1 + Stage 2 +// pipeline on every arch: build -> encode -> Open -> Lookup, asserting C++ +// demangling and inline-frame recovery (demo::inner inlined into demo::outer). +func TestBuildFromMachO_SymbolicatesInlineChain(t *testing.T) { + arches, err := BuildFromMachO(fixtureDWARF) + require.NoError(t, err) + + for _, a := range arches { + t.Run(archName(a.CPUType), func(t *testing.T) { + outer := findFunc(a.Builder, "demo::outer") + require.NotNil(t, outer, "expected a demangled demo::outer function") + + inl := findInline(outer, "demo::inner") + require.NotNil(t, inl, "expected demo::inner inlined into demo::outer") + + var buf bytes.Buffer + require.NoError(t, a.Builder.Encode(&buf)) + m, err := dsymmap.Open(buf.Bytes()) + require.NoError(t, err) + assert.Equal(t, a.Builder.UUID, m.UUID()) + + // Inside the inlined region: innermost demo::inner, then demo::outer. + mid := inl.Start + (inl.End-inl.Start)/2 + frames := m.Lookup(mid) + require.GreaterOrEqual(t, len(frames), 2, "inline lookup should expand to >= 2 frames") + assert.Contains(t, frames[0].Function, "demo::inner") + assert.Contains(t, frames[len(frames)-1].Function, "demo::outer") + assert.True(t, strings.HasSuffix(frames[0].File, "symbolsdemo.cpp"), + "innermost frame should carry a source file, got %q", frames[0].File) + assert.NotZero(t, frames[0].Line) + + // The function entry is before the loop body, so no inline covers it. + entry := m.Lookup(outer.Start) + require.Len(t, entry, 1, "function entry should resolve to a single frame") + assert.Contains(t, entry[0].Function, "demo::outer") + + // Far outside any function is a miss. + assert.Nil(t, m.Lookup(0xFFFFFFF0)) + }) + } +} + +func TestBuildFromMachO_MissingFile(t *testing.T) { + _, err := BuildFromMachO("testdata/does-not-exist") + require.Error(t, err) +} + +func findFunc(b *dsymmap.Builder, nameSubstr string) *dsymmap.Function { + for i := range b.Funcs { + if strings.Contains(b.Funcs[i].Name, nameSubstr) { + return &b.Funcs[i] + } + } + return nil +} + +func findInline(fn *dsymmap.Function, nameSubstr string) *dsymmap.Inline { + for i := range fn.Inlines { + if strings.Contains(fn.Inlines[i].Name, nameSubstr) { + return &fn.Inlines[i] + } + } + return nil +} + +func archName(cpuType uint32) string { + switch cpuType { + case 0x0100000C: + return "arm64" + case 0x01000007: + return "x86_64" + default: + return "cpu" + } +} diff --git a/internal/symbols/apple/testdata/build.sh b/internal/symbols/apple/testdata/build.sh new file mode 100755 index 00000000..5d74b839 --- /dev/null +++ b/internal/symbols/apple/testdata/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Regenerates the checked-in golden fixture: a universal (arm64 + x86_64) Mach-O +# with DWARF, packaged as symbolsdemo.dSYM. Run on macOS with Xcode tools. +# The resulting .dSYM is committed; the test never invokes this script. +# +# We compile per-arch objects and keep them until dsymutil has collected their +# DWARF (dsymutil reads debug info from the .o files referenced by the linked +# binary's debug map), then clean up the intermediates. +set -euo pipefail +cd "$(dirname "$0")" + +clang++ -g -O1 -arch arm64 -c symbolsdemo.cpp -o symbolsdemo-arm64.o +clang++ -g -O1 -arch x86_64 -c symbolsdemo.cpp -o symbolsdemo-x86_64.o +clang++ -g -arch arm64 symbolsdemo-arm64.o -o symbolsdemo-arm64 +clang++ -g -arch x86_64 symbolsdemo-x86_64.o -o symbolsdemo-x86_64 +lipo -create symbolsdemo-arm64 symbolsdemo-x86_64 -o symbolsdemo + +rm -rf symbolsdemo.dSYM +dsymutil symbolsdemo -o symbolsdemo.dSYM + +rm -f symbolsdemo symbolsdemo-arm64 symbolsdemo-x86_64 symbolsdemo-arm64.o symbolsdemo-x86_64.o + +echo "Rebuilt symbolsdemo.dSYM:" +/usr/bin/dwarfdump --uuid symbolsdemo.dSYM +echo "--- debug-info sanity (expect demo::outer / demo::inner) ---" +/usr/bin/dwarfdump --debug-info symbolsdemo.dSYM | grep -E "DW_AT_name|inlined_subroutine|subprogram" | head -30 diff --git a/internal/symbols/apple/testdata/symbolsdemo.cpp b/internal/symbols/apple/testdata/symbolsdemo.cpp new file mode 100644 index 00000000..c3cdbef8 --- /dev/null +++ b/internal/symbols/apple/testdata/symbolsdemo.cpp @@ -0,0 +1,29 @@ +// Fixture source for the dSYM -> .dsymmap golden test. Built into a checked-in +// universal .dSYM via build.sh; the test asserts UUID extraction, C++ +// demangling, and inline-frame recovery (demo::inner inlined into demo::outer). +// +// inner() writes through a volatile pointer so the optimizer cannot fold it to a +// closed form; that keeps a real inlined body (a DW_TAG_inlined_subroutine) at a +// distinct address inside outer(), which is what the inline-chain test needs. +namespace demo { + +__attribute__((always_inline)) inline int inner(volatile int* sink, int x) { + int v = x * x + 1; + *sink += v; + return v; +} + +__attribute__((noinline)) int outer(volatile int* sink, int n) { + int total = 0; + for (int i = 1; i <= n; i++) { + total += inner(sink, i); + } + return total; +} + +} // namespace demo + +int main(int argc, char** argv) { + volatile int sink = 0; + return demo::outer(&sink, argc); +} diff --git a/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Info.plist b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Info.plist new file mode 100644 index 00000000..835d8e8b --- /dev/null +++ b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleIdentifier + com.apple.xcode.dsym.symbolsdemo + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + dSYM + CFBundleSignature + ???? + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/DWARF/symbolsdemo b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/DWARF/symbolsdemo new file mode 100644 index 00000000..1078bcdb Binary files /dev/null and b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/DWARF/symbolsdemo differ diff --git a/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/aarch64/symbolsdemo.yml b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/aarch64/symbolsdemo.yml new file mode 100644 index 00000000..55565f4a --- /dev/null +++ b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/aarch64/symbolsdemo.yml @@ -0,0 +1,5 @@ +--- +triple: 'arm64-apple-darwin' +binary-path: symbolsdemo +relocations: [] +... diff --git a/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/x86_64/symbolsdemo.yml b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/x86_64/symbolsdemo.yml new file mode 100644 index 00000000..9dcf2e21 --- /dev/null +++ b/internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/x86_64/symbolsdemo.yml @@ -0,0 +1,5 @@ +--- +triple: 'x86_64-apple-darwin' +binary-path: symbolsdemo +relocations: [] +... diff --git a/internal/symbols/dsymmap/dsymmap.go b/internal/symbols/dsymmap/dsymmap.go new file mode 100644 index 00000000..7775d46c --- /dev/null +++ b/internal/symbols/dsymmap/dsymmap.go @@ -0,0 +1,455 @@ +// Package dsymmap implements the dSYM Map (".dsymmap") format: a compact, +// mmap-friendly, binary-searchable index that maps an image-relative instruction +// offset to a function name, source file:line, and any inlined call frames. +// +// It is produced by `ldcli symbols upload --type apple-dsym` from a Mach-O dSYM +// (one file per architecture, keyed by the build UUID) and consumed by the +// backend Apple enhancer. The two sides live in separate repos and intentionally +// duplicate this layout rather than share a module; this file is byte-for-byte +// identical to backend/stacktraces/dsymmap/dsymmap.go, and the `Magic`+`Version` +// header plus a shared golden file guard against drift. +// +// # Design goals +// +// - Zero-parse load: the reader operates directly over the raw bytes (which may +// be an mmap'd region); no unmarshal pass and no per-record allocation. +// - O(log n) lookup: the func/line tables are sorted, fixed-width arrays probed +// by binary search (address ranges are predecessor queries, not exact keys). +// - Small + off-heap: strings are deduplicated in a single table and referenced +// by u32 offsets. +// +// # Address space +// +// All addresses are "image-relative": file_addr - text_vmaddr, where file_addr is +// the address as it appears in DWARF and text_vmaddr is the __TEXT segment's +// vmaddr. At runtime the device reports rel_offset = instruction_addr - +// image_load_addr, which is algebraically identical, so the reader can use the +// device's rel_offset directly as the lookup key with no slide math. +// +// # Binary layout (little-endian) +// +// Header (72 bytes): +// 0 magic [4]byte "DSMP" +// 4 version u16 +// 6 flags u16 +// 8 cpuType u32 (mach cputype) +// 12 cpuSubtype u32 +// 16 uuid [16]byte +// 32 textVMAddr u64 (normalization reference; not needed at query time) +// 40 nFuncs u32 +// 44 funcsOff u32 +// 48 nLines u32 +// 52 linesOff u32 +// 56 nInlines u32 +// 60 inlinesOff u32 +// 64 strtabOff u32 +// 68 strtabLen u32 +// +// funcs[] (nFuncs × 32 bytes, sorted by addrStart, non-overlapping): +// 0 addrStart u64 +// 8 addrEnd u64 +// 16 nameOff u32 (into strtab: demangled function name) +// 20 inlineOff u32 (index into inlines[]; start of this func's group) +// 24 inlineCount u32 (number of inline records in this func's group) +// 28 _pad u32 +// +// lines[] (nLines × 16 bytes, sorted by addr): +// 0 addr u64 (row covers [addr, next-row.addr)) +// 8 fileOff u32 (into strtab: source file path; 0 == none) +// 12 line u32 +// +// inlines[] (nInlines × 32 bytes, grouped per func, sorted by depth ascending): +// 0 addrStart u64 +// 8 addrEnd u64 +// 16 nameOff u32 (into strtab: inlined function name) +// 20 callFileOff u32 (into strtab: call-site file in the caller) +// 24 callLine u32 (call-site line in the caller) +// 28 depth u32 (1 = outermost inline; increases inward) +// +// strtab: NUL-terminated UTF-8 strings; offset 0 is the empty string. +package dsymmap + +import ( + "encoding/binary" + "io" + "sort" + + e "github.com/pkg/errors" +) + +const ( + // Magic identifies the file format. + Magic = "DSMP" + // Version is bumped on any incompatible layout change; the reader rejects + // versions it does not understand. + Version = uint16(1) + + headerSize = 72 + funcRecSize = 32 + lineRecSize = 16 + inlineRecSize = 32 +) + +// Frame is one resolved stack frame. Lookup returns frames innermost-first +// (deepest inline first, physical function last), matching a deepest-frame-first +// stack ordering. +type Frame struct { + Function string + File string + Line uint32 +} + +// --- Builder (encode side) --- + +// Inline describes one inlined call recovered from DWARF. Name is the inlined +// function; CallFile/CallLine are where it was called from (in its caller). +// Depth is 1 for the outermost inline and increases inward. +type Inline struct { + Start, End uint64 + Name string + CallFile string + CallLine uint32 + Depth uint32 +} + +// Function is one physical function covering [Start, End). Inlines, if any, are +// the inlined calls contained within it. +type Function struct { + Start, End uint64 + Name string + Inlines []Inline +} + +// LineRow maps an address to a source file:line; it is valid until the next row. +type LineRow struct { + Addr uint64 + File string + Line uint32 +} + +// Builder accumulates the tables for one architecture/UUID and encodes them. +type Builder struct { + UUID [16]byte + TextVMAddr uint64 + CPUType uint32 + CPUSubtype uint32 + Funcs []Function + Lines []LineRow +} + +type strtab struct { + buf []byte + offsets map[string]uint32 +} + +func newStrtab() *strtab { + // Offset 0 is reserved for the empty string. + return &strtab{buf: []byte{0}, offsets: map[string]uint32{"": 0}} +} + +func (s *strtab) intern(str string) uint32 { + if off, ok := s.offsets[str]; ok { + return off + } + off := uint32(len(s.buf)) + s.buf = append(s.buf, str...) + s.buf = append(s.buf, 0) + s.offsets[str] = off + return off +} + +// Encode writes the .dsymmap representation to w. +func (b *Builder) Encode(w io.Writer) error { + funcs := append([]Function(nil), b.Funcs...) + sort.Slice(funcs, func(i, j int) bool { return funcs[i].Start < funcs[j].Start }) + lines := append([]LineRow(nil), b.Lines...) + sort.Slice(lines, func(i, j int) bool { return lines[i].Addr < lines[j].Addr }) + + st := newStrtab() + + funcBuf := make([]byte, 0, len(funcs)*funcRecSize) + var inlineBuf []byte + var inlineCount uint32 + for _, fn := range funcs { + ins := append([]Inline(nil), fn.Inlines...) + sort.Slice(ins, func(i, j int) bool { return ins[i].Depth < ins[j].Depth }) + inlineOff := inlineCount + for _, in := range ins { + rec := make([]byte, inlineRecSize) + binary.LittleEndian.PutUint64(rec[0:], in.Start) + binary.LittleEndian.PutUint64(rec[8:], in.End) + binary.LittleEndian.PutUint32(rec[16:], st.intern(in.Name)) + binary.LittleEndian.PutUint32(rec[20:], st.intern(in.CallFile)) + binary.LittleEndian.PutUint32(rec[24:], in.CallLine) + binary.LittleEndian.PutUint32(rec[28:], in.Depth) + inlineBuf = append(inlineBuf, rec...) + inlineCount++ + } + + rec := make([]byte, funcRecSize) + binary.LittleEndian.PutUint64(rec[0:], fn.Start) + binary.LittleEndian.PutUint64(rec[8:], fn.End) + binary.LittleEndian.PutUint32(rec[16:], st.intern(fn.Name)) + binary.LittleEndian.PutUint32(rec[20:], inlineOff) + binary.LittleEndian.PutUint32(rec[24:], uint32(len(ins))) + funcBuf = append(funcBuf, rec...) + } + + lineBuf := make([]byte, 0, len(lines)*lineRecSize) + for _, ln := range lines { + rec := make([]byte, lineRecSize) + binary.LittleEndian.PutUint64(rec[0:], ln.Addr) + binary.LittleEndian.PutUint32(rec[8:], st.intern(ln.File)) + binary.LittleEndian.PutUint32(rec[12:], ln.Line) + lineBuf = append(lineBuf, rec...) + } + + funcsOff := uint32(headerSize) + linesOff := funcsOff + uint32(len(funcBuf)) + inlinesOff := linesOff + uint32(len(lineBuf)) + strtabOff := inlinesOff + uint32(len(inlineBuf)) + + hdr := make([]byte, headerSize) + copy(hdr[0:4], Magic) + binary.LittleEndian.PutUint16(hdr[4:], Version) + binary.LittleEndian.PutUint16(hdr[6:], 0) + binary.LittleEndian.PutUint32(hdr[8:], b.CPUType) + binary.LittleEndian.PutUint32(hdr[12:], b.CPUSubtype) + copy(hdr[16:32], b.UUID[:]) + binary.LittleEndian.PutUint64(hdr[32:], b.TextVMAddr) + binary.LittleEndian.PutUint32(hdr[40:], uint32(len(funcs))) + binary.LittleEndian.PutUint32(hdr[44:], funcsOff) + binary.LittleEndian.PutUint32(hdr[48:], uint32(len(lines))) + binary.LittleEndian.PutUint32(hdr[52:], linesOff) + binary.LittleEndian.PutUint32(hdr[56:], inlineCount) + binary.LittleEndian.PutUint32(hdr[60:], inlinesOff) + binary.LittleEndian.PutUint32(hdr[64:], strtabOff) + binary.LittleEndian.PutUint32(hdr[68:], uint32(len(st.buf))) + + for _, chunk := range [][]byte{hdr, funcBuf, lineBuf, inlineBuf, st.buf} { + if _, err := w.Write(chunk); err != nil { + return e.Wrap(err, "writing dsymmap") + } + } + return nil +} + +// --- Map (decode + lookup side) --- + +// Map is a read-only view over encoded .dsymmap bytes (typically an mmap'd region). +// It does not copy or parse the record sections; lookups index into data. +type Map struct { + data []byte + uuid [16]byte + textVMAddr uint64 + cpuType uint32 + cpuSubtype uint32 + + funcs []byte + nFuncs int + lines []byte + nLines int + inlines []byte + + strtab []byte +} + +// Open validates the header and returns a Map over data. data must remain valid +// (not unmapped) for the lifetime of the Map. +func Open(data []byte) (*Map, error) { + if len(data) < headerSize { + return nil, e.New("dsymmap: data shorter than header") + } + if string(data[0:4]) != Magic { + return nil, e.Errorf("dsymmap: bad magic %q", data[0:4]) + } + if v := binary.LittleEndian.Uint16(data[4:]); v != Version { + return nil, e.Errorf("dsymmap: unsupported version %d (want %d)", v, Version) + } + + m := &Map{data: data} + m.cpuType = binary.LittleEndian.Uint32(data[8:]) + m.cpuSubtype = binary.LittleEndian.Uint32(data[12:]) + copy(m.uuid[:], data[16:32]) + m.textVMAddr = binary.LittleEndian.Uint64(data[32:]) + + nFuncs := binary.LittleEndian.Uint32(data[40:]) + funcsOff := binary.LittleEndian.Uint32(data[44:]) + nLines := binary.LittleEndian.Uint32(data[48:]) + linesOff := binary.LittleEndian.Uint32(data[52:]) + nInlines := binary.LittleEndian.Uint32(data[56:]) + inlinesOff := binary.LittleEndian.Uint32(data[60:]) + strtabOff := binary.LittleEndian.Uint32(data[64:]) + strtabLen := binary.LittleEndian.Uint32(data[68:]) + + var err error + if m.funcs, err = section(data, funcsOff, nFuncs, funcRecSize, "funcs"); err != nil { + return nil, err + } + if m.lines, err = section(data, linesOff, nLines, lineRecSize, "lines"); err != nil { + return nil, err + } + if m.inlines, err = section(data, inlinesOff, nInlines, inlineRecSize, "inlines"); err != nil { + return nil, err + } + if m.strtab, err = section(data, strtabOff, strtabLen, 1, "strtab"); err != nil { + return nil, err + } + if strtabLen == 0 || m.strtab[len(m.strtab)-1] != 0 { + return nil, e.New("dsymmap: strtab not NUL-terminated") + } + m.nFuncs = int(nFuncs) + m.nLines = int(nLines) + return m, nil +} + +func section(data []byte, off, count, size uint32, name string) ([]byte, error) { + end := uint64(off) + uint64(count)*uint64(size) + if uint64(off) > uint64(len(data)) || end > uint64(len(data)) { + return nil, e.Errorf("dsymmap: %s section out of bounds", name) + } + return data[off:end], nil +} + +// UUID returns the build UUID this map symbolicates. +func (m *Map) UUID() [16]byte { return m.uuid } + +// CPU returns the mach cputype/cpusubtype this map was built for. +func (m *Map) CPU() (uint32, uint32) { return m.cpuType, m.cpuSubtype } + +func (m *Map) str(off uint32) string { + if int(off) >= len(m.strtab) { + return "" + } + b := m.strtab[off:] + for i := 0; i < len(b); i++ { + if b[i] == 0 { + return string(b[:i]) + } + } + return string(b) +} + +func (m *Map) funcAt(i int) (start, end uint64, nameOff, inlineOff, inlineCount uint32) { + r := m.funcs[i*funcRecSize:] + return binary.LittleEndian.Uint64(r[0:]), + binary.LittleEndian.Uint64(r[8:]), + binary.LittleEndian.Uint32(r[16:]), + binary.LittleEndian.Uint32(r[20:]), + binary.LittleEndian.Uint32(r[24:]) +} + +func (m *Map) lineAt(i int) (addr uint64, fileOff, line uint32) { + r := m.lines[i*lineRecSize:] + return binary.LittleEndian.Uint64(r[0:]), + binary.LittleEndian.Uint32(r[8:]), + binary.LittleEndian.Uint32(r[12:]) +} + +func (m *Map) inlineAt(i int) (start, end uint64, nameOff, callFileOff, callLine, depth uint32) { + r := m.inlines[i*inlineRecSize:] + return binary.LittleEndian.Uint64(r[0:]), + binary.LittleEndian.Uint64(r[8:]), + binary.LittleEndian.Uint32(r[16:]), + binary.LittleEndian.Uint32(r[20:]), + binary.LittleEndian.Uint32(r[24:]), + binary.LittleEndian.Uint32(r[28:]) +} + +// Lookup resolves an image-relative offset to a stack of frames, innermost +// (deepest inline) first and the physical function last. It returns nil when no +// function covers the offset (the caller then falls back to the on-device +// symbol or module+offset). +func (m *Map) Lookup(relOffset uint64) []Frame { + fi := m.findFunc(relOffset) + if fi < 0 { + return nil + } + _, _, nameOff, inlineOff, inlineCount := m.funcAt(fi) + funcName := m.str(nameOff) + + // Deepest source location at the offset (used for the innermost frame). + locFile, locLine := m.lineLoc(relOffset) + + // Collect the inline records of this function that cover the offset, then + // order them outermost..innermost by depth so we can assemble the chain. + covering := m.coveringInlines(relOffset, inlineOff, inlineCount) + + frames := make([]Frame, 0, len(covering)+1) + for k := len(covering) - 1; k >= 0; k-- { + _, _, nOff, cFileOff, cLine, _ := m.inlineAt(covering[k]) + frames = append(frames, Frame{Function: m.str(nOff), File: locFile, Line: locLine}) + // The next (more-outer) frame is located at this inline's call site. + locFile, locLine = m.str(cFileOff), cLine + } + frames = append(frames, Frame{Function: funcName, File: locFile, Line: locLine}) + return frames +} + +// findFunc returns the index of the function covering rel, or -1. +func (m *Map) findFunc(rel uint64) int { + // Greatest addrStart <= rel. + lo, hi := 0, m.nFuncs + for lo < hi { + mid := (lo + hi) / 2 + s, _, _, _, _ := m.funcAt(mid) + if s <= rel { + lo = mid + 1 + } else { + hi = mid + } + } + i := lo - 1 + if i < 0 { + return -1 + } + _, end, _, _, _ := m.funcAt(i) + if rel >= end { + return -1 + } + return i +} + +// lineLoc returns the file:line of the row covering rel (greatest addr <= rel). +func (m *Map) lineLoc(rel uint64) (string, uint32) { + lo, hi := 0, m.nLines + for lo < hi { + mid := (lo + hi) / 2 + a, _, _ := m.lineAt(mid) + if a <= rel { + lo = mid + 1 + } else { + hi = mid + } + } + i := lo - 1 + if i < 0 { + return "", 0 + } + _, fileOff, line := m.lineAt(i) + return m.str(fileOff), line +} + +// coveringInlines returns indices (into inlines[]) of this function's inline +// records that contain rel, ordered by depth ascending (outermost first). +func (m *Map) coveringInlines(rel uint64, off, count uint32) []int { + if count == 0 { + return nil + } + var out []int + for j := uint32(0); j < count; j++ { + idx := int(off + j) + s, en, _, _, _, _ := m.inlineAt(idx) + if rel >= s && rel < en { + out = append(out, idx) + } + } + // Records are stored depth-ascending within a func group, so out is already + // ordered outermost..innermost; sort defensively in case a producer differs. + sort.Slice(out, func(a, b int) bool { + _, _, _, _, _, da := m.inlineAt(out[a]) + _, _, _, _, _, db := m.inlineAt(out[b]) + return da < db + }) + return out +}