From 997653075aff74f6e21a9866ab6bdcc9d57e024a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 21 Jul 2026 19:16:17 -0700 Subject: [PATCH 1/9] apple dsym ingest --- cmd/symbols/apple_upload.go | 203 ++++++++ cmd/symbols/apple_upload_test.go | 97 ++++ cmd/symbols/generate.go | 188 ++++++++ cmd/symbols/symbols.go | 1 + cmd/symbols/upload.go | 19 +- cmd/symbols/upload_test.go | 10 +- go.mod | 3 + go.sum | 6 + internal/symbols/apple/dsym.go | 381 +++++++++++++++ internal/symbols/apple/dsym_test.go | 112 +++++ internal/symbols/apple/testdata/build.sh | 26 + .../symbols/apple/testdata/symbolsdemo.cpp | 29 ++ .../symbolsdemo.dSYM/Contents/Info.plist | 20 + .../Contents/Resources/DWARF/symbolsdemo | Bin 0 -> 19876 bytes .../Relocations/aarch64/symbolsdemo.yml | 5 + .../Relocations/x86_64/symbolsdemo.yml | 5 + internal/symbols/ldsm/ldsm.go | 456 ++++++++++++++++++ 17 files changed, 1556 insertions(+), 5 deletions(-) create mode 100644 cmd/symbols/apple_upload.go create mode 100644 cmd/symbols/apple_upload_test.go create mode 100644 cmd/symbols/generate.go create mode 100644 internal/symbols/apple/dsym.go create mode 100644 internal/symbols/apple/dsym_test.go create mode 100755 internal/symbols/apple/testdata/build.sh create mode 100644 internal/symbols/apple/testdata/symbolsdemo.cpp create mode 100644 internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Info.plist create mode 100644 internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/DWARF/symbolsdemo create mode 100644 internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/aarch64/symbolsdemo.yml create mode 100644 internal/symbols/apple/testdata/symbolsdemo.dSYM/Contents/Resources/Relocations/x86_64/symbolsdemo.yml create mode 100644 internal/symbols/ldsm/ldsm.go diff --git a/cmd/symbols/apple_upload.go b/cmd/symbols/apple_upload.go new file mode 100644 index 00000000..98cacdca --- /dev/null +++ b/cmd/symbols/apple_upload.go @@ -0,0 +1,203 @@ +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/. The backend derives the same +// key from the image_uuid the device reports for a crashing frame. +const appleSymbolsIDPrefix = "_sym/apple/id" + +// appleSymbolMap is one architecture's compiled .ldsm 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 .ldsm 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 .ldsm, +// 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", appleSymbolsIDPrefix, uuid) +} + +// 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..1a74a1d5 --- /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/ldsm" +) + +// 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", 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 .ldsm 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 .ldsm the backend can read. + parsed, err := ldsm.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 key[strings.LastIndex(key, "/")+1:] +} diff --git a/cmd/symbols/generate.go b/cmd/symbols/generate.go new file mode 100644 index 00000000..6ce7f3f0 --- /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 := viper.GetString(typeFlag) + if !isSupportedType(symbolType) { + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s", symbolType, 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 .ldsm 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 .ldsm symbol maps +// and writes one file per build UUID under outputDir, using the same storage +// key (_sym/apple/id/) 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)", typeReactNative, typeAndroid, 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..bc81a00d 100644 --- a/cmd/symbols/upload.go +++ b/cmd/symbols/upload.go @@ -62,6 +62,10 @@ const ( // stack-trace retrace. typeAndroid = "android" + // typeAppleDSYM compiles Apple dSYM debug info into per-architecture .ldsm + // symbol maps (keyed by build UUID) for iOS/macOS crash symbolication. + 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 +108,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( @@ -131,7 +135,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error return func(cmd *cobra.Command, args []string) error { symbolType := 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", symbolType, typeReactNative, typeAndroid, typeAppleDSYM) } projectKey := viper.GetString(cliflags.ProjectFlag) @@ -173,6 +177,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 .ldsm + // 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) @@ -230,7 +241,7 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } 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 +472,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)", typeReactNative, typeAndroid, 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..f323ef20 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,11 @@ 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("")) +} 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..8a83da9b --- /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 .ldsm 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/ldsm" +) + +// 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 *ldsm.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 .ldsm 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 := &ldsm.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 *ldsm.Function + inlineDepth uint32 +} + +func populate(d *dwarf.Data, textVM uint64, b *ldsm.Builder) error { + r := d.Reader() + var stack []scope + var funcs []*ldsm.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) *ldsm.Function { + lo, hi, ok := spanOf(d, ent, textVM) + if !ok { + return nil + } + return &ldsm.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) []ldsm.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([]ldsm.Inline, 0, len(ranges)) + for _, rng := range ranges { + start, end, ok := relRange(rng[0], rng[1], textVM) + if !ok { + continue + } + out = append(out, ldsm.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 *ldsm.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, ldsm.LineRow{Addr: rel}) + continue + } + file := "" + if le.File != nil { + file = le.File.Name + } + b.Lines = append(b.Lines, ldsm.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..c68bb95c --- /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/ldsm" +) + +// 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 := ldsm.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 *ldsm.Builder, nameSubstr string) *ldsm.Function { + for i := range b.Funcs { + if strings.Contains(b.Funcs[i].Name, nameSubstr) { + return &b.Funcs[i] + } + } + return nil +} + +func findInline(fn *ldsm.Function, nameSubstr string) *ldsm.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..24cf22ac --- /dev/null +++ b/internal/symbols/apple/testdata/symbolsdemo.cpp @@ -0,0 +1,29 @@ +// Fixture source for the dSYM -> .ldsm 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 0000000000000000000000000000000000000000..1078bcdb5926f2a6009acf252353d5455ba1ac27 GIT binary patch literal 19876 zcmeHNUu;`f89&GOUdQou;%hs>jk9G=*Rf0m+v(D!g>+3x(k5-%C27*71LQfj+n6}E zWxH8Ag=JKom?lxE&>rA{3=L`H0U;(N#t>ByV-J(2@zAI7022=&B*eocnD{gJefOTz zI8JO8hG3h{Cw=#v@7!~K=R3c9b8~&}@AdayxxpB7amHFuYVcvkK7JFWfi@pX*u7D- zT}8#9sMg;6>mT34Ku!a_a02P8>D7&R5B$Y1z4YA7uO4{)Dc^Ud7e_Ar{eWo49IyzG z;V>7R`ZLQ(CV{daVHbQHo=i?1J2Cdi*vvx+>de8aYpe?i=lAera&~NfcKbLQ3KxnvQJ>#$1m1kj zAel=|)2-?jUy~DOPK}LD$hBg9+tw-8n-U}GoPA!zgtLgblgZJCkIftxttg^(IEcw^ zx}8$qOmt6>C6k%#!ljGJe6Emn;@$CHwtM3cm6hE~iI*y;3Kz4L9r?YC@wAR%F`VLN zCh_vcbUs(9R_fRLF2)ny6#+tVGn06!Or}iz>hT`$b?^=e5Q>|b#1ps24!oBHUNk+=k z!TX#5p}3hzJi7mqNV5KVR0Q5>0YY&zlX!*HGVZ>e{A~)n4zXT}UCjX1ImLitKrx^g zPz)#r6a$I@#lVMv0eYwP;~iK2-VNKa^P`epefNv=o8tE?y(vF(W+0PYE*>mis%Fb$ zQ|EFyG%hbDv#Z%Onv#pzR3=+y$>mh8fZ@s%1BwB~fMP%~pcqgLC%O8%4^Sw{wcezZXhaaJAfBb{dzVDCy;SJt1+0hqz zHO*RGa`!-Inx7kC@1$Y4V_M;)(W3=D^F}yk_}$v&su6Mz8K(P`;U6)2Cfr&fW`wn1 z%rH*6&3?DG6f?EXn9GHEfhl(!fZ4^3pm)RYeGjnayvvOaLrBE=9_OK)ZtY*3up`Fg z=%|S6izm=P>n7v)nD6DYx_3l>p3iRRBPaMQrqEfN7GN8CTR)%D?>~ye(GhBpr#Mda z@frQ#AfMI;kMJ4BT}YNnLuUbg9;ylgp3}V%0<;6n618Vx10n;-T~+L`d_$CS1#gM? zIZ=jjVGPFY)H&!AZ9$xzfXeI9bEJ!Wpy*RjRkw7Tu<8ujwZF4x#ck%Ym z<1h>-^IL{)ZK7f$1+^%u{9J%V2*#7eAt>tf0XoDYKCCn3y1wRN-J59Xn7~2rg$~hPK-T%jO0A?+%wtg!TAs?6rWkO9YS!Q$|vaFPkoV}t%eHsLqnZ^3^Je;fW=_+Q}vfd3Ot z`l}YW3Af;#@Cf{UEx!Ij5U@-XbJ|7^0P_1pIXQXmbOINXPNKX0+{DAg9!EMKGWiHe zJo&WQJN#Y$Gv9u8?F;MQTzls;y=T+WXV+$b-etLO%O6k-iN*sCQF++fTc`{^tJNk>J<!=h6U$%%-JT& zlnCj1IM>8!Ae>$GZBunPQK$da|5V>5@UA&9j0rp$)|XOW)JWfUP2eTOz-zaO*GS)X zL*P9v@NV2DUL$?m@o9%YncwkV0ouscOTM>9t-?nm_c#ZUJ*Jqsk?B@V-y1utT zVxn2**GS*?!dVAT>f2tp`?m3R)wjJi>)=U!+iUayY+^FMM*6nBc-x`-Uh3QS+K zfD*5fzO5wiq`s{LylsE~jiJ>U#eiZ!F`yVw3@8Q^1BwB~zz3EAdZ#6QoBX|tT|mT^ zoiDvlOMTmm;`gi4x7pOFgJM82pcqgLC*nN_OY^R}PyP|cTG?@F#P_4aV_)d{(?TX6> z-Pk9Pjq{bw(OXE|QUgw}itOn$sniZZgm!2M`mw1EeP|TH=(ru&Cp#cROAH{M#>sI$ z?KIjFwvHh_k2XiW2AmG8VMFg2AdQ-?vpe`_ZS9%`MC%S32_c&WIIGWwW0W_sDD#zC zHMwi@s@OeQ73F_Xqc%=UFW%Cqk=jG3)LKa2B2;Rg2T>O)wN_hN=fMY9sZ#TjO3m5y z2?b3)MIW_Ie*O>&UmAyDI9VGsYVDh-_(^44P|OIx-`#rU0SQurFrXhX{OYr`J^1J zZA*^U?#R*njvUQuC`Suym!k!x94(~eXpp*nl;mi((2aCRw6`jao^|9O1wGnVX;Its LXm(3eJ=(tjt}R8* literal 0 HcmV?d00001 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/ldsm/ldsm.go b/internal/symbols/ldsm/ldsm.go new file mode 100644 index 00000000..a101823f --- /dev/null +++ b/internal/symbols/ldsm/ldsm.go @@ -0,0 +1,456 @@ +// Package ldsm implements the LaunchDarkly Symbol Map (".ldsm") 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/ldsm/ldsm.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 "LDSM" +// 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 ldsm + +import ( + "encoding/binary" + "io" + "sort" + + e "github.com/pkg/errors" +) + +const ( + // Magic identifies the file format. + Magic = "LDSM" + // 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 .ldsm 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 ldsm") + } + } + return nil +} + +// --- Map (decode + lookup side) --- + +// Map is a read-only view over encoded .ldsm 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("ldsm: data shorter than header") + } + if string(data[0:4]) != Magic { + return nil, e.Errorf("ldsm: bad magic %q", data[0:4]) + } + if v := binary.LittleEndian.Uint16(data[4:]); v != Version { + return nil, e.Errorf("ldsm: 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("ldsm: 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("ldsm: %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 +} From 42786abcd777298875cda70b4a53ada4f2e66d3e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 22 Jul 2026 08:56:14 -0700 Subject: [PATCH 2/9] refactor(symbols): rename ldsm to dsymmap + apple type aliases Rename the symbol-map package/format from ldsm to dsymmap (magic DSMP), append the .dsymmap extension to the apple upload key, and accept apple/ios/dsym aliases for the --type flag. Co-authored-by: Cursor --- cmd/symbols/apple_upload.go | 17 +++++--- cmd/symbols/apple_upload_test.go | 12 +++--- cmd/symbols/generate.go | 12 +++--- cmd/symbols/upload.go | 41 ++++++++++++++++--- cmd/symbols/upload_test.go | 23 +++++++++++ internal/symbols/apple/dsym.go | 32 +++++++-------- internal/symbols/apple/dsym_test.go | 8 ++-- .../symbols/apple/testdata/symbolsdemo.cpp | 2 +- .../{ldsm/ldsm.go => dsymmap/dsymmap.go} | 31 +++++++------- 9 files changed, 117 insertions(+), 61 deletions(-) rename internal/symbols/{ldsm/ldsm.go => dsymmap/dsymmap.go} (93%) diff --git a/cmd/symbols/apple_upload.go b/cmd/symbols/apple_upload.go index 98cacdca..caf9d162 100644 --- a/cmd/symbols/apple_upload.go +++ b/cmd/symbols/apple_upload.go @@ -13,11 +13,16 @@ import ( ) // appleSymbolsIDPrefix is the storage segment for Apple symbol maps. Each map is -// keyed by its build UUID: _sym/apple/id/. The backend derives the same -// key from the image_uuid the device reports for a crashing frame. +// 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" -// appleSymbolMap is one architecture's compiled .ldsm ready to upload. +// 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 @@ -26,7 +31,7 @@ type appleSymbolMap struct { } // uploadAppleDSYMs discovers .dSYM bundles under path, compiles each contained -// architecture to a .ldsm symbol map, and uploads one object per build UUID. +// 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 { @@ -69,7 +74,7 @@ func uploadAppleDSYMs(apiKey, projectID, path, backendURL string) error { return nil } -// buildAppleMaps compiles every architecture of every dSYM image into a .ldsm, +// 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 @@ -104,7 +109,7 @@ func buildAppleMaps(images []string) ([]appleSymbolMap, error) { } func appleKey(uuid string) string { - return fmt.Sprintf("%s/%s", appleSymbolsIDPrefix, uuid) + return fmt.Sprintf("%s/%s%s", appleSymbolsIDPrefix, uuid, appleSymbolExt) } // findDSYMImages resolves path to the DWARF Mach-O images to symbolicate. path diff --git a/cmd/symbols/apple_upload_test.go b/cmd/symbols/apple_upload_test.go index 1a74a1d5..9bf6c3fd 100644 --- a/cmd/symbols/apple_upload_test.go +++ b/cmd/symbols/apple_upload_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/launchdarkly/ldcli/internal/symbols/ldsm" + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" ) // fixtureDSYM is the checked-in universal dSYM shared with the apple package's @@ -20,7 +20,7 @@ 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", appleKey("ABC123")) + assert.Equal(t, "_sym/apple/id/ABC123.dsymmap", appleKey("ABC123")) } func TestFindDSYMImages_Bundle(t *testing.T) { @@ -54,7 +54,7 @@ func TestFindDSYMImages_Missing(t *testing.T) { } // TestBuildAppleMaps exercises the command's build/encode/dedupe step against -// the real fixture and confirms the produced bytes are valid, keyed .ldsm maps. +// 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}) @@ -69,8 +69,8 @@ func TestBuildAppleMaps(t *testing.T) { assert.False(t, keys[m.Key], "each arch gets a distinct key") keys[m.Key] = true - // The uploaded bytes must decode as a valid .ldsm the backend can read. - parsed, err := ldsm.Open(m.Data) + // 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 @@ -93,5 +93,5 @@ func TestArchLabel(t *testing.T) { } func keySuffixHex(key string) string { - return key[strings.LastIndex(key, "/")+1:] + return strings.TrimSuffix(key[strings.LastIndex(key, "/")+1:], appleSymbolExt) } diff --git a/cmd/symbols/generate.go b/cmd/symbols/generate.go index 6ce7f3f0..544af7dd 100644 --- a/cmd/symbols/generate.go +++ b/cmd/symbols/generate.go @@ -60,9 +60,9 @@ func NewGenerateCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { func generateRunE() 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, %s", symbolType, typeReactNative, typeAndroid, typeAppleDSYM) + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM) } path := viper.GetString(pathFlag) @@ -73,7 +73,7 @@ func generateRunE() func(cmd *cobra.Command, args []string) error { fmt.Printf("Generating %s symbols from %s into %s\n", symbolType, path, outputDir) - // Apple dSYMs are compiled into per-arch .ldsm symbol maps keyed by build + // 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) @@ -83,9 +83,9 @@ func generateRunE() func(cmd *cobra.Command, args []string) error { } } -// generateAppleDSYMs compiles the discovered dSYM images to .ldsm symbol maps +// 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/) that `symbols upload` would use. +// key (_sym/apple/id/.dsymmap) that `symbols upload` would use. func generateAppleDSYMs(path, outputDir string) error { images, err := findDSYMImages(path) if err != nil { @@ -166,7 +166,7 @@ func writeSymbolFile(outputDir, key string, data []byte) error { } func initGenerateFlags(cmd *cobra.Command) { - cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to generate (supported: %s, %s, %s)", typeReactNative, typeAndroid, typeAppleDSYM)) + 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)) diff --git a/cmd/symbols/upload.go b/cmd/symbols/upload.go index bc81a00d..0250df71 100644 --- a/cmd/symbols/upload.go +++ b/cmd/symbols/upload.go @@ -62,8 +62,9 @@ const ( // stack-trace retrace. typeAndroid = "android" - // typeAppleDSYM compiles Apple dSYM debug info into per-architecture .ldsm - // symbol maps (keyed by build UUID) for iOS/macOS crash symbolication. + // 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 @@ -133,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, %s", symbolType, typeReactNative, typeAndroid, typeAppleDSYM) + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM) } projectKey := viper.GetString(cliflags.ProjectFlag) @@ -177,7 +178,7 @@ 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 .ldsm + // 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) @@ -240,6 +241,34 @@ 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 || symbolType == typeAppleDSYM } @@ -472,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, %s)", typeReactNative, typeAndroid, typeAppleDSYM)) + 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 f323ef20..9fc2b2ed 100644 --- a/cmd/symbols/upload_test.go +++ b/cmd/symbols/upload_test.go @@ -264,3 +264,26 @@ func TestIsSupportedType(t *testing.T) { 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/internal/symbols/apple/dsym.go b/internal/symbols/apple/dsym.go index 8a83da9b..5870128e 100644 --- a/internal/symbols/apple/dsym.go +++ b/internal/symbols/apple/dsym.go @@ -1,5 +1,5 @@ // Package apple converts an Apple dSYM (Mach-O + DWARF) into the compact, -// per-architecture .ldsm symbol maps the backend consumes. +// 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 @@ -25,7 +25,7 @@ import ( "github.com/ianlancetaylor/demangle" e "github.com/pkg/errors" - "github.com/launchdarkly/ldcli/internal/symbols/ldsm" + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" ) // Arch is one architecture's symbol map recovered from a dSYM image. @@ -35,12 +35,12 @@ type Arch struct { UUID string CPUType uint32 CPUSubtype uint32 - Builder *ldsm.Builder + 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 .ldsm keyed by Arch.UUID. +// 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. @@ -92,7 +92,7 @@ func buildArch(f *macho.File) (Arch, error) { textVM = seg.Addr } - b := &ldsm.Builder{ + b := &dsymmap.Builder{ UUID: uuid, TextVMAddr: textVM, CPUType: uint32(f.CPU), @@ -115,14 +115,14 @@ func buildArch(f *macho.File) (Arch, error) { // function to attach to); inlineDepth is how many inlined_subroutine ancestors // deep we are (0 inside the physical function body). type scope struct { - fn *ldsm.Function + fn *dsymmap.Function inlineDepth uint32 } -func populate(d *dwarf.Data, textVM uint64, b *ldsm.Builder) error { +func populate(d *dwarf.Data, textVM uint64, b *dsymmap.Builder) error { r := d.Reader() var stack []scope - var funcs []*ldsm.Function + var funcs []*dsymmap.Function var curFiles []*dwarf.LineFile top := func() scope { @@ -183,17 +183,17 @@ func populate(d *dwarf.Data, textVM uint64, b *ldsm.Builder) error { // 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) *ldsm.Function { +func makeFunction(d *dwarf.Data, ent *dwarf.Entry, textVM uint64) *dsymmap.Function { lo, hi, ok := spanOf(d, ent, textVM) if !ok { return nil } - return &ldsm.Function{Start: lo, End: hi, Name: entryName(d, ent)} + 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) []ldsm.Inline { +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 @@ -201,13 +201,13 @@ func makeInlines(d *dwarf.Data, ent *dwarf.Entry, textVM uint64, depth uint32, f name := entryName(d, ent) callFile, callLine := callSite(ent, files) - out := make([]ldsm.Inline, 0, len(ranges)) + 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, ldsm.Inline{ + out = append(out, dsymmap.Inline{ Start: start, End: end, Name: name, @@ -263,7 +263,7 @@ func callSite(ent *dwarf.Entry, files []*dwarf.LineFile) (string, uint32) { return file, line } -func addLines(d *dwarf.Data, cu *dwarf.Entry, textVM uint64, b *ldsm.Builder) { +func addLines(d *dwarf.Data, cu *dwarf.Entry, textVM uint64, b *dsymmap.Builder) { lr, err := d.LineReader(cu) if err != nil || lr == nil { return @@ -280,14 +280,14 @@ func addLines(d *dwarf.Data, cu *dwarf.Entry, textVM uint64, b *ldsm.Builder) { 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, ldsm.LineRow{Addr: rel}) + b.Lines = append(b.Lines, dsymmap.LineRow{Addr: rel}) continue } file := "" if le.File != nil { file = le.File.Name } - b.Lines = append(b.Lines, ldsm.LineRow{Addr: rel, File: file, Line: uint32(le.Line)}) + b.Lines = append(b.Lines, dsymmap.LineRow{Addr: rel, File: file, Line: uint32(le.Line)}) } } diff --git a/internal/symbols/apple/dsym_test.go b/internal/symbols/apple/dsym_test.go index c68bb95c..bd42d2ca 100644 --- a/internal/symbols/apple/dsym_test.go +++ b/internal/symbols/apple/dsym_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/launchdarkly/ldcli/internal/symbols/ldsm" + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" ) // fixtureDWARF is the DWARF Mach-O inside the checked-in universal .dSYM. @@ -52,7 +52,7 @@ func TestBuildFromMachO_SymbolicatesInlineChain(t *testing.T) { var buf bytes.Buffer require.NoError(t, a.Builder.Encode(&buf)) - m, err := ldsm.Open(buf.Bytes()) + m, err := dsymmap.Open(buf.Bytes()) require.NoError(t, err) assert.Equal(t, a.Builder.UUID, m.UUID()) @@ -82,7 +82,7 @@ func TestBuildFromMachO_MissingFile(t *testing.T) { require.Error(t, err) } -func findFunc(b *ldsm.Builder, nameSubstr string) *ldsm.Function { +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] @@ -91,7 +91,7 @@ func findFunc(b *ldsm.Builder, nameSubstr string) *ldsm.Function { return nil } -func findInline(fn *ldsm.Function, nameSubstr string) *ldsm.Inline { +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] diff --git a/internal/symbols/apple/testdata/symbolsdemo.cpp b/internal/symbols/apple/testdata/symbolsdemo.cpp index 24cf22ac..c3cdbef8 100644 --- a/internal/symbols/apple/testdata/symbolsdemo.cpp +++ b/internal/symbols/apple/testdata/symbolsdemo.cpp @@ -1,4 +1,4 @@ -// Fixture source for the dSYM -> .ldsm golden test. Built into a checked-in +// 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). // diff --git a/internal/symbols/ldsm/ldsm.go b/internal/symbols/dsymmap/dsymmap.go similarity index 93% rename from internal/symbols/ldsm/ldsm.go rename to internal/symbols/dsymmap/dsymmap.go index a101823f..7775d46c 100644 --- a/internal/symbols/ldsm/ldsm.go +++ b/internal/symbols/dsymmap/dsymmap.go @@ -1,13 +1,12 @@ -// Package ldsm implements the LaunchDarkly Symbol Map (".ldsm") 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. +// 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/ldsm/ldsm.go, and the `Magic`+`Version` +// identical to backend/stacktraces/dsymmap/dsymmap.go, and the `Magic`+`Version` // header plus a shared golden file guard against drift. // // # Design goals @@ -30,7 +29,7 @@ // # Binary layout (little-endian) // // Header (72 bytes): -// 0 magic [4]byte "LDSM" +// 0 magic [4]byte "DSMP" // 4 version u16 // 6 flags u16 // 8 cpuType u32 (mach cputype) @@ -68,7 +67,7 @@ // 28 depth u32 (1 = outermost inline; increases inward) // // strtab: NUL-terminated UTF-8 strings; offset 0 is the empty string. -package ldsm +package dsymmap import ( "encoding/binary" @@ -80,7 +79,7 @@ import ( const ( // Magic identifies the file format. - Magic = "LDSM" + Magic = "DSMP" // Version is bumped on any incompatible layout change; the reader rejects // versions it does not understand. Version = uint16(1) @@ -159,7 +158,7 @@ func (s *strtab) intern(str string) uint32 { return off } -// Encode writes the .ldsm representation to w. +// 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 }) @@ -229,7 +228,7 @@ func (b *Builder) Encode(w io.Writer) error { for _, chunk := range [][]byte{hdr, funcBuf, lineBuf, inlineBuf, st.buf} { if _, err := w.Write(chunk); err != nil { - return e.Wrap(err, "writing ldsm") + return e.Wrap(err, "writing dsymmap") } } return nil @@ -237,7 +236,7 @@ func (b *Builder) Encode(w io.Writer) error { // --- Map (decode + lookup side) --- -// Map is a read-only view over encoded .ldsm bytes (typically an mmap'd region). +// 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 @@ -259,13 +258,13 @@ type Map struct { // (not unmapped) for the lifetime of the Map. func Open(data []byte) (*Map, error) { if len(data) < headerSize { - return nil, e.New("ldsm: data shorter than header") + return nil, e.New("dsymmap: data shorter than header") } if string(data[0:4]) != Magic { - return nil, e.Errorf("ldsm: bad magic %q", data[0:4]) + return nil, e.Errorf("dsymmap: bad magic %q", data[0:4]) } if v := binary.LittleEndian.Uint16(data[4:]); v != Version { - return nil, e.Errorf("ldsm: unsupported version %d (want %d)", v, Version) + return nil, e.Errorf("dsymmap: unsupported version %d (want %d)", v, Version) } m := &Map{data: data} @@ -297,7 +296,7 @@ func Open(data []byte) (*Map, error) { return nil, err } if strtabLen == 0 || m.strtab[len(m.strtab)-1] != 0 { - return nil, e.New("ldsm: strtab not NUL-terminated") + return nil, e.New("dsymmap: strtab not NUL-terminated") } m.nFuncs = int(nFuncs) m.nLines = int(nLines) @@ -307,7 +306,7 @@ func Open(data []byte) (*Map, error) { 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("ldsm: %s section out of bounds", name) + return nil, e.Errorf("dsymmap: %s section out of bounds", name) } return data[off:end], nil } From 4404252f86035610c21a5ace35961673debe9243 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 23 Jul 2026 14:06:16 -0700 Subject: [PATCH 3/9] fix demagling --- internal/symbols/apple/dsym.go | 14 ++++++++++++-- internal/symbols/apple/dsym_test.go | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/symbols/apple/dsym.go b/internal/symbols/apple/dsym.go index 5870128e..bc06c768 100644 --- a/internal/symbols/apple/dsym.go +++ b/internal/symbols/apple/dsym.go @@ -343,11 +343,21 @@ func resolveName(d *dwarf.Data, off dwarf.Offset, depth int) (name, linkage stri 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. +// bestName prefers a demangled linkage name and falls back to the plain +// DW_AT_name, then the raw linkage, then a placeholder. +// +// For Swift it uses the simplified form (matching `swift-demangle -simplified`): +// the full demangling keeps a private declaration's discriminator hash +// (e.g. "cast in _17034F2FACCE8EAB00EC5D8288C5BB0D") plus verbose +// specialization/signature noise, which reads as a broken/partial symbol in the +// UI. Simplified renders it as "MainMenuViewModel.cast(_:to:)". Falls back to +// the full demangling if the simplified form is unavailable. func bestName(name, linkage string) string { if linkage != "" { if swift.IsMangled(linkage) { + if s, err := swift.DemangleSimple(linkage); err == nil && s != "" && s != linkage { + return s + } if s, err := swift.Demangle(linkage); err == nil && s != "" { return s } diff --git a/internal/symbols/apple/dsym_test.go b/internal/symbols/apple/dsym_test.go index bd42d2ca..b11176a0 100644 --- a/internal/symbols/apple/dsym_test.go +++ b/internal/symbols/apple/dsym_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/blacktop/go-macho/pkg/swift" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -82,6 +83,24 @@ func TestBuildFromMachO_MissingFile(t *testing.T) { require.Error(t, err) } +// TestBestNameSwiftSimplified verifies that a private Swift declaration is +// rendered without its discriminator hash ("... in _"), which otherwise +// surfaces as a partial-looking symbol. Skips when no platform Swift demangler +// is available (the pure-Go engine passes symbols through unchanged). +func TestBestNameSwiftSimplified(t *testing.T) { + const mangled = "$s12TestAppFruta17MainMenuViewModelC12runCatchable33_17034F2FACCE8EAB00EC5D8288C5BB0DLLyyAA13CrashScenarioOKFTf4nd_n" + require.True(t, swift.IsMangled(mangled)) + + simple, err := swift.DemangleSimple(mangled) + if err != nil || simple == "" || simple == mangled { + t.Skipf("swift demangler unavailable (engine=%s)", swift.EngineMode()) + } + + got := bestName("", mangled) + assert.NotContains(t, got, "17034F2FACCE8EAB00EC5D8288C5BB0D", "private discriminator hash must be stripped") + assert.Contains(t, got, "runCatchable") +} + func findFunc(b *dsymmap.Builder, nameSubstr string) *dsymmap.Function { for i := range b.Funcs { if strings.Contains(b.Funcs[i].Name, nameSubstr) { From 931010ea6cb909d77523aac57c3a65c04186140b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 23 Jul 2026 14:16:58 -0700 Subject: [PATCH 4/9] panic fix --- internal/symbols/dsymmap/dsymmap.go | 19 +++++++--- internal/symbols/dsymmap/dsymmap_test.go | 44 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 internal/symbols/dsymmap/dsymmap_test.go diff --git a/internal/symbols/dsymmap/dsymmap.go b/internal/symbols/dsymmap/dsymmap.go index 7775d46c..55733a09 100644 --- a/internal/symbols/dsymmap/dsymmap.go +++ b/internal/symbols/dsymmap/dsymmap.go @@ -436,12 +436,23 @@ func (m *Map) coveringInlines(rel uint64, off, count uint32) []int { if count == 0 { return nil } + // off/count come straight from the (untrusted) file record; a corrupt or + // malicious map could point past the inlines table. Clamp to the records + // that actually exist so a malformed map yields fewer frames instead of an + // out-of-range slice panic in inlineAt. + total := uint32(len(m.inlines) / inlineRecSize) + if off >= total { + return nil + } + end := off + count + if end < off || end > total { // end < off guards uint32 overflow + end = total + } var out []int - for j := uint32(0); j < count; j++ { - idx := int(off + j) - s, en, _, _, _, _ := m.inlineAt(idx) + for idx := off; idx < end; idx++ { + s, en, _, _, _, _ := m.inlineAt(int(idx)) if rel >= s && rel < en { - out = append(out, idx) + out = append(out, int(idx)) } } // Records are stored depth-ascending within a func group, so out is already diff --git a/internal/symbols/dsymmap/dsymmap_test.go b/internal/symbols/dsymmap/dsymmap_test.go new file mode 100644 index 00000000..4ef869bd --- /dev/null +++ b/internal/symbols/dsymmap/dsymmap_test.go @@ -0,0 +1,44 @@ +package dsymmap + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLookupCorruptInlineCount ensures a map whose function claims more inline +// records than the inlines table actually holds is clamped during lookup rather +// than triggering an out-of-range slice panic in inlineAt. +func TestLookupCorruptInlineCount(t *testing.T) { + b := &Builder{ + TextVMAddr: 0x100000000, + CPUType: 0x0100000C, + Funcs: []Function{{ + Start: 0x1000, End: 0x1100, Name: "outer", + Inlines: []Inline{{Start: 0x1040, End: 0x1080, Name: "inner", CallFile: "outer.swift", CallLine: 12, Depth: 1}}, + }}, + Lines: []LineRow{ + {Addr: 0x1000, File: "outer.swift", Line: 8}, + {Addr: 0x1040, File: "inner.swift", Line: 30}, + }, + } + var buf bytes.Buffer + require.NoError(t, b.Encode(&buf)) + data := buf.Bytes() + + // Corrupt func[0].inlineCount (record offset 24) so its group runs far past + // the single real inline record. + funcsOff := binary.LittleEndian.Uint32(data[44:]) + binary.LittleEndian.PutUint32(data[funcsOff+24:], 0xFFFFFFFF) + + m, err := Open(data) + require.NoError(t, err) + + assert.NotPanics(t, func() { + frames := m.Lookup(0x1050) // inside the (valid) inline range + require.NotEmpty(t, frames) + }) +} From 94cacca32ddcabc4dff19efe2dca6301bd76bfec Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 23 Jul 2026 18:07:53 -0700 Subject: [PATCH 5/9] feat(symbols): add Flutter (Dart AOT) symbol upload (--type flutter) Parse Flutter ELF app..symbols files (debug/elf + debug/dwarf), extract the Dart build id (.note.gnu.build-id) and DWARF, and compile them into the compact .dartmap symbol map (dsymmap codec). Upload to the Symbols Id lane (keyed by build id) and the Version lane (version + platform), and support local generation via `symbols generate`. Co-authored-by: Cursor --- cmd/symbols/flutter_upload.go | 176 +++++++++++++ cmd/symbols/generate.go | 27 +- cmd/symbols/upload.go | 21 +- cmd/symbols/upload_test.go | 31 ++- internal/symbols/flutter/elf.go | 366 +++++++++++++++++++++++++++ internal/symbols/flutter/elf_test.go | 61 +++++ 6 files changed, 673 insertions(+), 9 deletions(-) create mode 100644 cmd/symbols/flutter_upload.go create mode 100644 internal/symbols/flutter/elf.go create mode 100644 internal/symbols/flutter/elf_test.go diff --git a/cmd/symbols/flutter_upload.go b/cmd/symbols/flutter_upload.go new file mode 100644 index 00000000..789c67ee --- /dev/null +++ b/cmd/symbols/flutter_upload.go @@ -0,0 +1,176 @@ +package symbols + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/launchdarkly/ldcli/internal/symbols/flutter" +) + +const ( + // flutterSymbolsIDPrefix is the Id-lane storage segment for Flutter/Dart + // symbol maps. Each map is keyed by the Dart snapshot build id (symbols_id): + // _sym/flutter/id//app.dartmap. The backend derives the same key + // from the symbols_id an obfuscated crash reports. + flutterSymbolsIDPrefix = "_sym/flutter/id" + + // flutterSymbolExt is the object extension for a compiled Flutter symbol map + // (the shared dsymmap binary format under a Flutter-specific name). + flutterSymbolExt = ".dartmap" + + // flutterSymbolMapName is the Id-lane object name. A symbols_id is unique per + // (build, arch), so it fully identifies one map and no platform token is + // needed in the Id-lane key. + flutterSymbolMapName = "app" + flutterSymbolExt + + // flutterSymbolFileSuffix is the Flutter debug-symbols file `ldcli` discovers + // for --type flutter (e.g. app.android-arm64.symbols). + flutterSymbolFileSuffix = ".symbols" +) + +// flutterUpload is one .dartmap object to store at one key. A map is uploaded to +// the Id lane always, and to the Version lane too when --app-version is given +// (same bytes, two keys). +type flutterUpload struct { + Data []byte + Key string + Label string +} + +// uploadFlutterSymbols discovers app.*.symbols files under path, compiles each +// to a .dartmap, and uploads it to the Id lane (and the Version lane when +// appVersion is set). +func uploadFlutterSymbols(apiKey, projectID, path, appVersion, backendURL string) error { + uploads, err := buildFlutterMaps(path, appVersion) + if err != nil { + return err + } + + keys := make([]string, len(uploads)) + for i, u := range uploads { + keys[i] = u.Key + } + + uploadURLs, err := getSymbolUploadUrls(apiKey, projectID, keys, backendURL) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %w", err) + } + // One URL per requested key, in order; a short list would misalign the pairing. + if len(uploadURLs) != len(uploads) { + return fmt.Errorf("expected %d upload URLs but received %d", len(uploads), len(uploadURLs)) + } + + for i, u := range uploads { + if err := uploadBytes(u.Data, uploadURLs[i], u.Label); err != nil { + return fmt.Errorf("failed to upload symbol map %s: %w", u.Label, err) + } + } + + fmt.Println("Successfully uploaded all symbols") + return nil +} + +// buildFlutterMaps compiles every discovered app.*.symbols to a .dartmap and +// returns the objects to store, deduplicating by symbols_id (the same build can +// be discovered more than once). Each map yields an Id-lane upload, plus a +// Version-lane upload when appVersion and a platform token are both available. +func buildFlutterMaps(path, appVersion string) ([]flutterUpload, error) { + files, err := findFlutterSymbolFiles(path) + if err != nil { + return nil, fmt.Errorf("failed to find Flutter symbol files: %w", err) + } + if len(files) == 0 { + return nil, fmt.Errorf("no Flutter symbol files found in %s (looked for app.*.symbols). Build with `flutter build ... --obfuscate --split-debug-info=`", path) + } + + var uploads []flutterUpload + seen := make(map[string]bool) + for _, file := range files { + img, err := flutter.BuildFromELF(file) + if err != nil { + return nil, fmt.Errorf("failed to process %s: %w", file, err) + } + if img.SymbolsID == "" { + return nil, fmt.Errorf("no build id found in %s", file) + } + if seen[img.SymbolsID] { + continue + } + seen[img.SymbolsID] = true + + var buf bytes.Buffer + if err := img.Builder.Encode(&buf); err != nil { + return nil, fmt.Errorf("failed to encode symbol map for %s: %w", img.SymbolsID, err) + } + data := buf.Bytes() + + uploads = append(uploads, flutterUpload{ + Data: data, + Key: flutterIDKey(img.SymbolsID), + Label: fmt.Sprintf("%s (%s, Id Lane)", img.SymbolsID, img.Platform), + }) + if appVersion != "" && img.Platform != "" { + uploads = append(uploads, flutterUpload{ + Data: data, + Key: flutterVersionKey(appVersion, img.Platform), + Label: fmt.Sprintf("%s (%s, Version Lane)", img.SymbolsID, img.Platform), + }) + } + fmt.Printf("Built symbol map for %s (%s, %d bytes)\n", img.SymbolsID, img.Platform, len(data)) + } + return uploads, nil +} + +// flutterIDKey is the Id-lane storage key for a symbols_id: +// _sym/flutter/id//app.dartmap. +func flutterIDKey(symbolsID string) string { + return fmt.Sprintf("%s/%s/%s", flutterSymbolsIDPrefix, symbolsID, flutterSymbolMapName) +} + +// flutterVersionKey is the Version-lane storage key for a platform token: +// /app..dartmap. The platform token (e.g. "android-arm64") +// disambiguates the per-arch maps that share one app version, and matches the +// "-" the backend builds from the crash header. +func flutterVersionKey(version, platform string) string { + return fmt.Sprintf("%s/app.%s%s", version, platform, flutterSymbolExt) +} + +// findFlutterSymbolFiles resolves path to the app.*.symbols files to compile. +// path may be a single .symbols file or a directory tree (e.g. the +// --split-debug-info output folder). +func findFlutterSymbolFiles(path string) ([]string, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + if !isFlutterSymbolFile(path) { + return nil, fmt.Errorf("file %s is not a Flutter symbol file (expected app.*.symbols)", path) + } + return []string{path}, nil + } + + var out []string + err = filepath.WalkDir(path, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !d.IsDir() && isFlutterSymbolFile(p) { + out = append(out, p) + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +func isFlutterSymbolFile(name string) bool { + base := filepath.Base(name) + return strings.HasPrefix(base, "app.") && strings.HasSuffix(base, flutterSymbolFileSuffix) +} diff --git a/cmd/symbols/generate.go b/cmd/symbols/generate.go index 544af7dd..ecde7585 100644 --- a/cmd/symbols/generate.go +++ b/cmd/symbols/generate.go @@ -62,7 +62,7 @@ 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) + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter) } path := viper.GetString(pathFlag) @@ -79,6 +79,12 @@ func generateRunE() func(cmd *cobra.Command, args []string) error { return generateAppleDSYMs(path, outputDir) } + // Flutter symbols compile to .dartmap maps keyed by build id (Id Lane), + // plus a Version-lane copy when --app-version is set. + if symbolType == typeFlutter { + return generateFlutterSymbols(path, viper.GetString(appVersionFlag), outputDir) + } + return generateSymbolFiles(symbolType, path, outputDir) } } @@ -113,6 +119,23 @@ func generateAppleDSYMs(path, outputDir string) error { return nil } +// generateFlutterSymbols compiles the discovered app.*.symbols to .dartmap +// symbol maps and writes them under outputDir using the same storage keys +// `symbols upload` would use (Id lane, plus Version lane when appVersion is set). +func generateFlutterSymbols(path, appVersion, outputDir string) error { + uploads, err := buildFlutterMaps(path, appVersion) + if err != nil { + return err + } + for _, u := range uploads { + if err := writeSymbolFile(outputDir, u.Key, u.Data); err != nil { + return fmt.Errorf("failed to write symbol map %s: %w", u.Label, err) + } + } + fmt.Printf("Successfully generated %d symbol file(s) in %s\n", len(uploads), 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. @@ -166,7 +189,7 @@ func writeSymbolFile(outputDir, key string, data []byte) error { } 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.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to generate (supported: %s, %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym; %s also accepts dart)", typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter, typeAppleDSYM, typeFlutter)) _ = cmd.MarkFlagRequired(typeFlag) _ = cmd.Flags().SetAnnotation(typeFlag, "required", []string{"true"}) _ = viper.BindPFlag(typeFlag, cmd.Flags().Lookup(typeFlag)) diff --git a/cmd/symbols/upload.go b/cmd/symbols/upload.go index 0250df71..603dca80 100644 --- a/cmd/symbols/upload.go +++ b/cmd/symbols/upload.go @@ -67,6 +67,11 @@ const ( // the canonical value; see symbolTypeAliases for accepted synonyms. typeAppleDSYM = "apple-dsym" + // typeFlutter compiles Flutter/Dart AOT debug symbols (app..symbols) + // into per-build .dartmap symbol maps (keyed by the Dart snapshot build id, + // surfaced as symbols_id) for obfuscated Dart crash symbolication. + typeFlutter = "flutter" + // 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. @@ -136,7 +141,7 @@ func runE(client resources.Client) 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) + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter) } projectKey := viper.GetString(cliflags.ProjectFlag) @@ -185,6 +190,14 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error return uploadAppleDSYMs(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, path, backendUrl) } + // Flutter/Dart symbols take a dedicated path too: each app..symbols + // is compiled to a .dartmap keyed by its build id (Id Lane), plus a + // Version-lane copy when --app-version is set. + if symbolType == typeFlutter { + fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path) + return uploadFlutterSymbols(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, path, appVersion, backendUrl) + } + symbolsIDPrefix := symbolsIDPrefixForType(symbolType) fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path) @@ -255,6 +268,8 @@ var symbolTypeAliases = map[string]string{ "visionos": typeAppleDSYM, "macos": typeAppleDSYM, "osx": typeAppleDSYM, + "flutter": typeFlutter, + "dart": typeFlutter, } // canonicalizeSymbolType resolves a user-supplied --type to its canonical value. @@ -270,7 +285,7 @@ func canonicalizeSymbolType(symbolType string) string { } func isSupportedType(symbolType string) bool { - return symbolType == typeReactNative || symbolType == typeAndroid || symbolType == typeAppleDSYM + return symbolType == typeReactNative || symbolType == typeAndroid || symbolType == typeAppleDSYM || symbolType == typeFlutter } // symbolsIDPrefixForType picks the Symbols Id Lane storage segment for the symbol @@ -501,7 +516,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, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym)", typeReactNative, typeAndroid, typeAppleDSYM, typeAppleDSYM)) + cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to upload (supported: %s, %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym; %s also accepts dart)", typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter, typeAppleDSYM, typeFlutter)) _ = 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 9fc2b2ed..117565ad 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, "flutter") + viper.Set(typeFlag, "totally-unknown") defer viper.Set(typeFlag, "") client := resources.NewClient("") @@ -261,7 +261,8 @@ 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.True(t, isSupportedType(typeFlutter)) + assert.False(t, isSupportedType("totally-unknown")) assert.False(t, isSupportedType("")) } @@ -271,6 +272,11 @@ func TestCanonicalizeSymbolType(t *testing.T) { assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType(alias), alias) } + // Flutter synonyms resolve to the flutter type. + assert.Equal(t, typeFlutter, canonicalizeSymbolType("flutter")) + assert.Equal(t, typeFlutter, canonicalizeSymbolType("dart")) + assert.Equal(t, typeFlutter, canonicalizeSymbolType("Flutter")) + // Case-insensitive and whitespace-tolerant. assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType("iOS")) assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType(" Apple-DSYM ")) @@ -279,11 +285,28 @@ func TestCanonicalizeSymbolType(t *testing.T) { // 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"))) + assert.Equal(t, "totally-unknown", canonicalizeSymbolType("Totally-Unknown")) + assert.False(t, isSupportedType(canonicalizeSymbolType("totally-unknown"))) // Every alias must map to a supported canonical type. for alias, canonical := range symbolTypeAliases { assert.True(t, isSupportedType(canonical), alias) } } + +func TestFlutterKeys(t *testing.T) { + symbolsID := "0f8a1b2c3d4e5f60718293a4b5c6d7e8" + // Id lane: keyed by symbols_id (arch-unique), constant object name. + assert.Equal(t, "_sym/flutter/id/"+symbolsID+"/app.dartmap", flutterIDKey(symbolsID)) + // Version lane: keyed by version, with the platform token disambiguating arches. + assert.Equal(t, "1.2.3/app.android-arm64.dartmap", flutterVersionKey("1.2.3", "android-arm64")) + assert.Equal(t, "1.2.3/app.ios-arm64.dartmap", flutterVersionKey("1.2.3", "ios-arm64")) +} + +func TestIsFlutterSymbolFile(t *testing.T) { + assert.True(t, isFlutterSymbolFile("app.android-arm64.symbols")) + assert.True(t, isFlutterSymbolFile("build/symbols/app.ios-arm64.symbols")) + assert.False(t, isFlutterSymbolFile("app.android-arm64.dartmap")) + assert.False(t, isFlutterSymbolFile("mapping.txt")) + assert.False(t, isFlutterSymbolFile("other.symbols")) +} diff --git a/internal/symbols/flutter/elf.go b/internal/symbols/flutter/elf.go new file mode 100644 index 00000000..f2d74ecb --- /dev/null +++ b/internal/symbols/flutter/elf.go @@ -0,0 +1,366 @@ +// Package flutter converts a Flutter/Dart AOT debug-symbols file +// (app..symbols — an ELF containing DWARF) into the compact, +// mmap-friendly .dartmap symbol map the backend consumes. It is the ldcli-side +// (encode) half of the Flutter symbolication pipeline. +// +// A release Flutter build produced with `--obfuscate --split-debug-info=` +// strips Dart names and emits address-based crash frames plus a header carrying +// a build id. The matching per-arch .symbols ELF carries the same build id (as a +// GNU build-id note) and the DWARF needed to resolve those addresses back to +// Dart function + file:line, expanding inlined calls. +// +// This uses only the Go standard library (debug/elf + debug/dwarf) so ldcli +// cross-compiles cleanly, and reuses the dsymmap codec shared with Apple. Dart +// symbol names in the .symbols DWARF are already human-readable, so — unlike the +// Apple path — no demangling is applied. +package flutter + +import ( + "debug/dwarf" + "debug/elf" + "encoding/binary" + "encoding/hex" + "path/filepath" + "strings" + + e "github.com/pkg/errors" + + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" +) + +// ntGNUBuildID is the ELF note type for a GNU build id (NT_GNU_BUILD_ID). +const ntGNUBuildID = 3 + +// Image is one .symbols file's recovered symbol map. +type Image struct { + // SymbolsID is the Dart snapshot build id as lowercase hex — the Id-lane + // lookup key (the same value the VM prints in every obfuscated crash header + // and the backend recovers from it). Named symbols_id everywhere; it is not + // exposed under the Dart "build id" term. + SymbolsID string + // Platform is the "-" token (e.g. "android-arm64") parsed from the + // app..symbols filename — used for the Version-lane object name. + Platform string + Builder *dsymmap.Builder +} + +// BuildFromELF opens a Flutter app..symbols ELF at path and returns +// its symbol map: the build id (symbols_id), platform token, and a dsymmap +// Builder the caller encodes to a .dartmap. +func BuildFromELF(path string) (Image, error) { + f, err := elf.Open(path) + if err != nil { + return Image{}, e.Wrapf(err, "opening %s", path) + } + defer f.Close() + + symbolsID, err := readBuildID(f) + if err != nil { + return Image{}, e.Wrapf(err, "reading build id from %s", path) + } + + d, err := f.DWARF() + if err != nil { + return Image{}, e.Wrapf(err, "reading DWARF from %s (was it built with --split-debug-info?)", path) + } + + // Dart AOT addresses in the crash `virt` column are already snapshot-relative + // and match the DWARF vaddr, so no rebasing is needed (TextVMAddr = 0). + b := &dsymmap.Builder{} + if err := populate(d, b); err != nil { + return Image{}, err + } + + return Image{ + SymbolsID: symbolsID, + Platform: platformFromFilename(path), + Builder: b, + }, nil +} + +// platformFromFilename extracts the "-" token from an +// app..symbols filename (e.g. "app.android-arm64.symbols" -> +// "android-arm64"). It returns "" when the name doesn't match, so the caller can +// fall back or surface a clear error; the Id lane does not depend on it. +func platformFromFilename(path string) string { + base := filepath.Base(path) + base = strings.TrimSuffix(base, ".symbols") + base = strings.TrimPrefix(base, "app.") + if base == "" || strings.Contains(base, ".") { + return "" + } + return base +} + +// readBuildID returns the GNU build id of an ELF as lowercase hex, scanning both +// note sections and PT_NOTE segments (Dart emits it as a .note.gnu.build-id). +func readBuildID(f *elf.File) (string, error) { + for _, sec := range f.Sections { + if sec.Type != elf.SHT_NOTE { + continue + } + data, err := sec.Data() + if err != nil { + continue + } + if id := buildIDFromNotes(data, f.ByteOrder); id != "" { + return id, nil + } + } + for _, prog := range f.Progs { + if prog.Type != elf.PT_NOTE { + continue + } + data := make([]byte, prog.Filesz) + if _, err := prog.ReadAt(data, 0); err != nil { + continue + } + if id := buildIDFromNotes(data, f.ByteOrder); id != "" { + return id, nil + } + } + return "", e.New("no GNU build-id note found") +} + +// buildIDFromNotes walks a note section/segment (a sequence of ELF notes) and +// returns the descriptor of the first NT_GNU_BUILD_ID note as lowercase hex, or +// "" if there is none. Each note is: namesz(4) descsz(4) type(4), then the name +// and descriptor, each padded to a 4-byte boundary. +func buildIDFromNotes(data []byte, bo binary.ByteOrder) string { + for len(data) >= 12 { + namesz := bo.Uint32(data[0:4]) + descsz := bo.Uint32(data[4:8]) + ntype := bo.Uint32(data[8:12]) + nameEnd := 12 + int(namesz) + descStart := align4(nameEnd) + descEnd := descStart + int(descsz) + if descEnd > len(data) || nameEnd > len(data) { + return "" + } + if ntype == ntGNUBuildID && descsz > 0 { + return hex.EncodeToString(data[descStart:descEnd]) + } + // Each note record is at least 12 bytes (header), so this always advances. + data = data[align4(descEnd):] + } + return "" +} + +func align4(n int) int { return (n + 3) &^ 3 } + +// scope is one level of the DWARF DIE tree during the depth-first walk (see the +// Apple builder for the shared shape). fn is the nearest enclosing concrete +// function; inlineDepth counts inlined_subroutine ancestors. +type scope struct { + fn *dsymmap.Function + inlineDepth uint32 +} + +// populate walks the DWARF DIE tree into b: physical functions, their line +// tables, and inlined-call chains. Mirrors apple.populate but on the standard +// library's debug/dwarf types. +func populate(d *dwarf.Data, 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() + + switch ent.Tag { + case dwarf.TagCompileUnit: + curFiles = filesForCU(d, ent) + addLines(d, ent, b) + + case dwarf.TagSubprogram: + if fn := makeFunction(d, ent); 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, depth, curFiles)...) + } + push.inlineDepth = depth + } + + if ent.Children { + stack = append(stack, push) + } + } + + for _, fp := range funcs { + b.Funcs = append(b.Funcs, *fp) + } + return nil +} + +func makeFunction(d *dwarf.Data, ent *dwarf.Entry) *dsymmap.Function { + lo, hi, ok := spanOf(d, ent) + if !ok { + return nil + } + return &dsymmap.Function{Start: lo, End: hi, Name: entryName(d, ent)} +} + +func makeInlines(d *dwarf.Data, ent *dwarf.Entry, 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 { + if rng[1] <= rng[0] { + continue + } + out = append(out, dsymmap.Inline{ + Start: rng[0], + End: rng[1], + Name: name, + CallFile: callFile, + CallLine: callLine, + Depth: depth, + }) + } + return out +} + +// spanOf returns the [low,high) covering all of an entry's PC ranges. +func spanOf(d *dwarf.Data, ent *dwarf.Entry) (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] + } + } + if hi <= lo { + return 0, 0, false + } + return lo, hi, true +} + +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, 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.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: le.Address}) + continue + } + file := "" + if le.File != nil { + file = le.File.Name + } + b.Lines = append(b.Lines, dsymmap.LineRow{Addr: le.Address, File: file, Line: uint32(le.Line)}) + } +} + +func filesForCU(d *dwarf.Data, cu *dwarf.Entry) []*dwarf.LineFile { + lr, err := d.LineReader(cu) + if err != nil || lr == nil { + return nil + } + return lr.Files() +} + +// entryName resolves a subprogram/inlined_subroutine name, following +// abstract_origin/specification references. Dart DWARF names are already +// readable, so no demangling is applied. +func entryName(d *dwarf.Data, ent *dwarf.Entry) string { + if name, _ := ent.Val(dwarf.AttrName).(string); name != "" { + return name + } + if off, ok := refOffset(ent); ok { + if name := resolveName(d, off, 0); name != "" { + return name + } + } + return "" +} + +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) string { + if depth > 4 { + return "" + } + r := d.Reader() + r.Seek(off) + ent, err := r.Next() + if err != nil || ent == nil { + return "" + } + if name, _ := ent.Val(dwarf.AttrName).(string); name != "" { + return name + } + if next, ok := refOffset(ent); ok { + return resolveName(d, next, depth+1) + } + return "" +} diff --git a/internal/symbols/flutter/elf_test.go b/internal/symbols/flutter/elf_test.go new file mode 100644 index 00000000..01ebc8df --- /dev/null +++ b/internal/symbols/flutter/elf_test.go @@ -0,0 +1,61 @@ +package flutter + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" +) + +// note builds a single ELF note record: namesz descsz type, then the padded +// name and descriptor, matching what buildIDFromNotes parses. +func note(bo binary.ByteOrder, name string, ntype uint32, desc []byte) []byte { + nameB := append([]byte(name), 0) // NUL-terminated + var b []byte + hdr := make([]byte, 12) + bo.PutUint32(hdr[0:], uint32(len(nameB))) + bo.PutUint32(hdr[4:], uint32(len(desc))) + bo.PutUint32(hdr[8:], ntype) + b = append(b, hdr...) + b = append(b, nameB...) + for len(b)%4 != 0 { + b = append(b, 0) + } + b = append(b, desc...) + for len(b)%4 != 0 { + b = append(b, 0) + } + return b +} + +func TestBuildIDFromNotes(t *testing.T) { + bo := binary.LittleEndian + id := []byte{0x0f, 0x8a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60} + + // A GNU build-id note on its own. + data := note(bo, "GNU", ntGNUBuildID, id) + assert.Equal(t, "0f8a1b2c3d4e5f60", buildIDFromNotes(data, bo)) + + // Preceded by an unrelated note (e.g. NT_GNU_ABI_TAG=1): must skip and find it. + data = append(note(bo, "GNU", 1, []byte{0, 0, 0, 0, 1, 2, 3, 4}), note(bo, "GNU", ntGNUBuildID, id)...) + assert.Equal(t, "0f8a1b2c3d4e5f60", buildIDFromNotes(data, bo)) + + // No build-id note present. + assert.Equal(t, "", buildIDFromNotes(note(bo, "GNU", 1, []byte{1, 2, 3, 4}), bo)) + + // Truncated data must not panic and must return "". + assert.Equal(t, "", buildIDFromNotes([]byte{1, 2, 3}, bo)) + + // Big-endian is honored. + be := binary.BigEndian + assert.Equal(t, "0f8a1b2c3d4e5f60", buildIDFromNotes(note(be, "GNU", ntGNUBuildID, id), be)) +} + +func TestPlatformFromFilename(t *testing.T) { + assert.Equal(t, "android-arm64", platformFromFilename("app.android-arm64.symbols")) + assert.Equal(t, "ios-arm64", platformFromFilename("build/symbols/app.ios-arm64.symbols")) + assert.Equal(t, "android-x64", platformFromFilename("app.android-x64.symbols")) + // Names that don't match the app..symbols shape yield "". + assert.Equal(t, "", platformFromFilename("app..symbols")) + assert.Equal(t, "", platformFromFilename("mapping.txt")) +} From a77aa5f3e8bed006e55ad05b120602680f539b8a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 23 Jul 2026 18:07:53 -0700 Subject: [PATCH 6/9] feat(symbols): add Flutter (Dart AOT) symbol upload (--type flutter) Parse Flutter ELF app..symbols files (debug/elf + debug/dwarf), extract the Dart build id (.note.gnu.build-id) and DWARF, and compile them into the compact .dartmap symbol map (dsymmap codec). Upload to the Symbols Id lane (keyed by build id) and the Version lane (version + platform), and support local generation via `symbols generate`. Co-authored-by: Cursor --- cmd/symbols/flutter_upload.go | 176 +++++++++++++ cmd/symbols/generate.go | 27 +- cmd/symbols/upload.go | 21 +- cmd/symbols/upload_test.go | 31 ++- internal/symbols/flutter/elf.go | 366 +++++++++++++++++++++++++++ internal/symbols/flutter/elf_test.go | 61 +++++ 6 files changed, 673 insertions(+), 9 deletions(-) create mode 100644 cmd/symbols/flutter_upload.go create mode 100644 internal/symbols/flutter/elf.go create mode 100644 internal/symbols/flutter/elf_test.go diff --git a/cmd/symbols/flutter_upload.go b/cmd/symbols/flutter_upload.go new file mode 100644 index 00000000..789c67ee --- /dev/null +++ b/cmd/symbols/flutter_upload.go @@ -0,0 +1,176 @@ +package symbols + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/launchdarkly/ldcli/internal/symbols/flutter" +) + +const ( + // flutterSymbolsIDPrefix is the Id-lane storage segment for Flutter/Dart + // symbol maps. Each map is keyed by the Dart snapshot build id (symbols_id): + // _sym/flutter/id//app.dartmap. The backend derives the same key + // from the symbols_id an obfuscated crash reports. + flutterSymbolsIDPrefix = "_sym/flutter/id" + + // flutterSymbolExt is the object extension for a compiled Flutter symbol map + // (the shared dsymmap binary format under a Flutter-specific name). + flutterSymbolExt = ".dartmap" + + // flutterSymbolMapName is the Id-lane object name. A symbols_id is unique per + // (build, arch), so it fully identifies one map and no platform token is + // needed in the Id-lane key. + flutterSymbolMapName = "app" + flutterSymbolExt + + // flutterSymbolFileSuffix is the Flutter debug-symbols file `ldcli` discovers + // for --type flutter (e.g. app.android-arm64.symbols). + flutterSymbolFileSuffix = ".symbols" +) + +// flutterUpload is one .dartmap object to store at one key. A map is uploaded to +// the Id lane always, and to the Version lane too when --app-version is given +// (same bytes, two keys). +type flutterUpload struct { + Data []byte + Key string + Label string +} + +// uploadFlutterSymbols discovers app.*.symbols files under path, compiles each +// to a .dartmap, and uploads it to the Id lane (and the Version lane when +// appVersion is set). +func uploadFlutterSymbols(apiKey, projectID, path, appVersion, backendURL string) error { + uploads, err := buildFlutterMaps(path, appVersion) + if err != nil { + return err + } + + keys := make([]string, len(uploads)) + for i, u := range uploads { + keys[i] = u.Key + } + + uploadURLs, err := getSymbolUploadUrls(apiKey, projectID, keys, backendURL) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %w", err) + } + // One URL per requested key, in order; a short list would misalign the pairing. + if len(uploadURLs) != len(uploads) { + return fmt.Errorf("expected %d upload URLs but received %d", len(uploads), len(uploadURLs)) + } + + for i, u := range uploads { + if err := uploadBytes(u.Data, uploadURLs[i], u.Label); err != nil { + return fmt.Errorf("failed to upload symbol map %s: %w", u.Label, err) + } + } + + fmt.Println("Successfully uploaded all symbols") + return nil +} + +// buildFlutterMaps compiles every discovered app.*.symbols to a .dartmap and +// returns the objects to store, deduplicating by symbols_id (the same build can +// be discovered more than once). Each map yields an Id-lane upload, plus a +// Version-lane upload when appVersion and a platform token are both available. +func buildFlutterMaps(path, appVersion string) ([]flutterUpload, error) { + files, err := findFlutterSymbolFiles(path) + if err != nil { + return nil, fmt.Errorf("failed to find Flutter symbol files: %w", err) + } + if len(files) == 0 { + return nil, fmt.Errorf("no Flutter symbol files found in %s (looked for app.*.symbols). Build with `flutter build ... --obfuscate --split-debug-info=`", path) + } + + var uploads []flutterUpload + seen := make(map[string]bool) + for _, file := range files { + img, err := flutter.BuildFromELF(file) + if err != nil { + return nil, fmt.Errorf("failed to process %s: %w", file, err) + } + if img.SymbolsID == "" { + return nil, fmt.Errorf("no build id found in %s", file) + } + if seen[img.SymbolsID] { + continue + } + seen[img.SymbolsID] = true + + var buf bytes.Buffer + if err := img.Builder.Encode(&buf); err != nil { + return nil, fmt.Errorf("failed to encode symbol map for %s: %w", img.SymbolsID, err) + } + data := buf.Bytes() + + uploads = append(uploads, flutterUpload{ + Data: data, + Key: flutterIDKey(img.SymbolsID), + Label: fmt.Sprintf("%s (%s, Id Lane)", img.SymbolsID, img.Platform), + }) + if appVersion != "" && img.Platform != "" { + uploads = append(uploads, flutterUpload{ + Data: data, + Key: flutterVersionKey(appVersion, img.Platform), + Label: fmt.Sprintf("%s (%s, Version Lane)", img.SymbolsID, img.Platform), + }) + } + fmt.Printf("Built symbol map for %s (%s, %d bytes)\n", img.SymbolsID, img.Platform, len(data)) + } + return uploads, nil +} + +// flutterIDKey is the Id-lane storage key for a symbols_id: +// _sym/flutter/id//app.dartmap. +func flutterIDKey(symbolsID string) string { + return fmt.Sprintf("%s/%s/%s", flutterSymbolsIDPrefix, symbolsID, flutterSymbolMapName) +} + +// flutterVersionKey is the Version-lane storage key for a platform token: +// /app..dartmap. The platform token (e.g. "android-arm64") +// disambiguates the per-arch maps that share one app version, and matches the +// "-" the backend builds from the crash header. +func flutterVersionKey(version, platform string) string { + return fmt.Sprintf("%s/app.%s%s", version, platform, flutterSymbolExt) +} + +// findFlutterSymbolFiles resolves path to the app.*.symbols files to compile. +// path may be a single .symbols file or a directory tree (e.g. the +// --split-debug-info output folder). +func findFlutterSymbolFiles(path string) ([]string, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + if !isFlutterSymbolFile(path) { + return nil, fmt.Errorf("file %s is not a Flutter symbol file (expected app.*.symbols)", path) + } + return []string{path}, nil + } + + var out []string + err = filepath.WalkDir(path, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !d.IsDir() && isFlutterSymbolFile(p) { + out = append(out, p) + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +func isFlutterSymbolFile(name string) bool { + base := filepath.Base(name) + return strings.HasPrefix(base, "app.") && strings.HasSuffix(base, flutterSymbolFileSuffix) +} diff --git a/cmd/symbols/generate.go b/cmd/symbols/generate.go index 544af7dd..ecde7585 100644 --- a/cmd/symbols/generate.go +++ b/cmd/symbols/generate.go @@ -62,7 +62,7 @@ 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) + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter) } path := viper.GetString(pathFlag) @@ -79,6 +79,12 @@ func generateRunE() func(cmd *cobra.Command, args []string) error { return generateAppleDSYMs(path, outputDir) } + // Flutter symbols compile to .dartmap maps keyed by build id (Id Lane), + // plus a Version-lane copy when --app-version is set. + if symbolType == typeFlutter { + return generateFlutterSymbols(path, viper.GetString(appVersionFlag), outputDir) + } + return generateSymbolFiles(symbolType, path, outputDir) } } @@ -113,6 +119,23 @@ func generateAppleDSYMs(path, outputDir string) error { return nil } +// generateFlutterSymbols compiles the discovered app.*.symbols to .dartmap +// symbol maps and writes them under outputDir using the same storage keys +// `symbols upload` would use (Id lane, plus Version lane when appVersion is set). +func generateFlutterSymbols(path, appVersion, outputDir string) error { + uploads, err := buildFlutterMaps(path, appVersion) + if err != nil { + return err + } + for _, u := range uploads { + if err := writeSymbolFile(outputDir, u.Key, u.Data); err != nil { + return fmt.Errorf("failed to write symbol map %s: %w", u.Label, err) + } + } + fmt.Printf("Successfully generated %d symbol file(s) in %s\n", len(uploads), 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. @@ -166,7 +189,7 @@ func writeSymbolFile(outputDir, key string, data []byte) error { } 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.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to generate (supported: %s, %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym; %s also accepts dart)", typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter, typeAppleDSYM, typeFlutter)) _ = cmd.MarkFlagRequired(typeFlag) _ = cmd.Flags().SetAnnotation(typeFlag, "required", []string{"true"}) _ = viper.BindPFlag(typeFlag, cmd.Flags().Lookup(typeFlag)) diff --git a/cmd/symbols/upload.go b/cmd/symbols/upload.go index 0250df71..603dca80 100644 --- a/cmd/symbols/upload.go +++ b/cmd/symbols/upload.go @@ -67,6 +67,11 @@ const ( // the canonical value; see symbolTypeAliases for accepted synonyms. typeAppleDSYM = "apple-dsym" + // typeFlutter compiles Flutter/Dart AOT debug symbols (app..symbols) + // into per-build .dartmap symbol maps (keyed by the Dart snapshot build id, + // surfaced as symbols_id) for obfuscated Dart crash symbolication. + typeFlutter = "flutter" + // 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. @@ -136,7 +141,7 @@ func runE(client resources.Client) 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) + return fmt.Errorf("unsupported --type %q; supported types: %s, %s, %s, %s", viper.GetString(typeFlag), typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter) } projectKey := viper.GetString(cliflags.ProjectFlag) @@ -185,6 +190,14 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error return uploadAppleDSYMs(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, path, backendUrl) } + // Flutter/Dart symbols take a dedicated path too: each app..symbols + // is compiled to a .dartmap keyed by its build id (Id Lane), plus a + // Version-lane copy when --app-version is set. + if symbolType == typeFlutter { + fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path) + return uploadFlutterSymbols(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, path, appVersion, backendUrl) + } + symbolsIDPrefix := symbolsIDPrefixForType(symbolType) fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path) @@ -255,6 +268,8 @@ var symbolTypeAliases = map[string]string{ "visionos": typeAppleDSYM, "macos": typeAppleDSYM, "osx": typeAppleDSYM, + "flutter": typeFlutter, + "dart": typeFlutter, } // canonicalizeSymbolType resolves a user-supplied --type to its canonical value. @@ -270,7 +285,7 @@ func canonicalizeSymbolType(symbolType string) string { } func isSupportedType(symbolType string) bool { - return symbolType == typeReactNative || symbolType == typeAndroid || symbolType == typeAppleDSYM + return symbolType == typeReactNative || symbolType == typeAndroid || symbolType == typeAppleDSYM || symbolType == typeFlutter } // symbolsIDPrefixForType picks the Symbols Id Lane storage segment for the symbol @@ -501,7 +516,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, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym)", typeReactNative, typeAndroid, typeAppleDSYM, typeAppleDSYM)) + cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to upload (supported: %s, %s, %s, %s; %s also accepts ios/ipados/tvos/watchos/visionos/macos/apple/dsym; %s also accepts dart)", typeReactNative, typeAndroid, typeAppleDSYM, typeFlutter, typeAppleDSYM, typeFlutter)) _ = 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 9fc2b2ed..117565ad 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, "flutter") + viper.Set(typeFlag, "totally-unknown") defer viper.Set(typeFlag, "") client := resources.NewClient("") @@ -261,7 +261,8 @@ 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.True(t, isSupportedType(typeFlutter)) + assert.False(t, isSupportedType("totally-unknown")) assert.False(t, isSupportedType("")) } @@ -271,6 +272,11 @@ func TestCanonicalizeSymbolType(t *testing.T) { assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType(alias), alias) } + // Flutter synonyms resolve to the flutter type. + assert.Equal(t, typeFlutter, canonicalizeSymbolType("flutter")) + assert.Equal(t, typeFlutter, canonicalizeSymbolType("dart")) + assert.Equal(t, typeFlutter, canonicalizeSymbolType("Flutter")) + // Case-insensitive and whitespace-tolerant. assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType("iOS")) assert.Equal(t, typeAppleDSYM, canonicalizeSymbolType(" Apple-DSYM ")) @@ -279,11 +285,28 @@ func TestCanonicalizeSymbolType(t *testing.T) { // 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"))) + assert.Equal(t, "totally-unknown", canonicalizeSymbolType("Totally-Unknown")) + assert.False(t, isSupportedType(canonicalizeSymbolType("totally-unknown"))) // Every alias must map to a supported canonical type. for alias, canonical := range symbolTypeAliases { assert.True(t, isSupportedType(canonical), alias) } } + +func TestFlutterKeys(t *testing.T) { + symbolsID := "0f8a1b2c3d4e5f60718293a4b5c6d7e8" + // Id lane: keyed by symbols_id (arch-unique), constant object name. + assert.Equal(t, "_sym/flutter/id/"+symbolsID+"/app.dartmap", flutterIDKey(symbolsID)) + // Version lane: keyed by version, with the platform token disambiguating arches. + assert.Equal(t, "1.2.3/app.android-arm64.dartmap", flutterVersionKey("1.2.3", "android-arm64")) + assert.Equal(t, "1.2.3/app.ios-arm64.dartmap", flutterVersionKey("1.2.3", "ios-arm64")) +} + +func TestIsFlutterSymbolFile(t *testing.T) { + assert.True(t, isFlutterSymbolFile("app.android-arm64.symbols")) + assert.True(t, isFlutterSymbolFile("build/symbols/app.ios-arm64.symbols")) + assert.False(t, isFlutterSymbolFile("app.android-arm64.dartmap")) + assert.False(t, isFlutterSymbolFile("mapping.txt")) + assert.False(t, isFlutterSymbolFile("other.symbols")) +} diff --git a/internal/symbols/flutter/elf.go b/internal/symbols/flutter/elf.go new file mode 100644 index 00000000..f2d74ecb --- /dev/null +++ b/internal/symbols/flutter/elf.go @@ -0,0 +1,366 @@ +// Package flutter converts a Flutter/Dart AOT debug-symbols file +// (app..symbols — an ELF containing DWARF) into the compact, +// mmap-friendly .dartmap symbol map the backend consumes. It is the ldcli-side +// (encode) half of the Flutter symbolication pipeline. +// +// A release Flutter build produced with `--obfuscate --split-debug-info=` +// strips Dart names and emits address-based crash frames plus a header carrying +// a build id. The matching per-arch .symbols ELF carries the same build id (as a +// GNU build-id note) and the DWARF needed to resolve those addresses back to +// Dart function + file:line, expanding inlined calls. +// +// This uses only the Go standard library (debug/elf + debug/dwarf) so ldcli +// cross-compiles cleanly, and reuses the dsymmap codec shared with Apple. Dart +// symbol names in the .symbols DWARF are already human-readable, so — unlike the +// Apple path — no demangling is applied. +package flutter + +import ( + "debug/dwarf" + "debug/elf" + "encoding/binary" + "encoding/hex" + "path/filepath" + "strings" + + e "github.com/pkg/errors" + + "github.com/launchdarkly/ldcli/internal/symbols/dsymmap" +) + +// ntGNUBuildID is the ELF note type for a GNU build id (NT_GNU_BUILD_ID). +const ntGNUBuildID = 3 + +// Image is one .symbols file's recovered symbol map. +type Image struct { + // SymbolsID is the Dart snapshot build id as lowercase hex — the Id-lane + // lookup key (the same value the VM prints in every obfuscated crash header + // and the backend recovers from it). Named symbols_id everywhere; it is not + // exposed under the Dart "build id" term. + SymbolsID string + // Platform is the "-" token (e.g. "android-arm64") parsed from the + // app..symbols filename — used for the Version-lane object name. + Platform string + Builder *dsymmap.Builder +} + +// BuildFromELF opens a Flutter app..symbols ELF at path and returns +// its symbol map: the build id (symbols_id), platform token, and a dsymmap +// Builder the caller encodes to a .dartmap. +func BuildFromELF(path string) (Image, error) { + f, err := elf.Open(path) + if err != nil { + return Image{}, e.Wrapf(err, "opening %s", path) + } + defer f.Close() + + symbolsID, err := readBuildID(f) + if err != nil { + return Image{}, e.Wrapf(err, "reading build id from %s", path) + } + + d, err := f.DWARF() + if err != nil { + return Image{}, e.Wrapf(err, "reading DWARF from %s (was it built with --split-debug-info?)", path) + } + + // Dart AOT addresses in the crash `virt` column are already snapshot-relative + // and match the DWARF vaddr, so no rebasing is needed (TextVMAddr = 0). + b := &dsymmap.Builder{} + if err := populate(d, b); err != nil { + return Image{}, err + } + + return Image{ + SymbolsID: symbolsID, + Platform: platformFromFilename(path), + Builder: b, + }, nil +} + +// platformFromFilename extracts the "-" token from an +// app..symbols filename (e.g. "app.android-arm64.symbols" -> +// "android-arm64"). It returns "" when the name doesn't match, so the caller can +// fall back or surface a clear error; the Id lane does not depend on it. +func platformFromFilename(path string) string { + base := filepath.Base(path) + base = strings.TrimSuffix(base, ".symbols") + base = strings.TrimPrefix(base, "app.") + if base == "" || strings.Contains(base, ".") { + return "" + } + return base +} + +// readBuildID returns the GNU build id of an ELF as lowercase hex, scanning both +// note sections and PT_NOTE segments (Dart emits it as a .note.gnu.build-id). +func readBuildID(f *elf.File) (string, error) { + for _, sec := range f.Sections { + if sec.Type != elf.SHT_NOTE { + continue + } + data, err := sec.Data() + if err != nil { + continue + } + if id := buildIDFromNotes(data, f.ByteOrder); id != "" { + return id, nil + } + } + for _, prog := range f.Progs { + if prog.Type != elf.PT_NOTE { + continue + } + data := make([]byte, prog.Filesz) + if _, err := prog.ReadAt(data, 0); err != nil { + continue + } + if id := buildIDFromNotes(data, f.ByteOrder); id != "" { + return id, nil + } + } + return "", e.New("no GNU build-id note found") +} + +// buildIDFromNotes walks a note section/segment (a sequence of ELF notes) and +// returns the descriptor of the first NT_GNU_BUILD_ID note as lowercase hex, or +// "" if there is none. Each note is: namesz(4) descsz(4) type(4), then the name +// and descriptor, each padded to a 4-byte boundary. +func buildIDFromNotes(data []byte, bo binary.ByteOrder) string { + for len(data) >= 12 { + namesz := bo.Uint32(data[0:4]) + descsz := bo.Uint32(data[4:8]) + ntype := bo.Uint32(data[8:12]) + nameEnd := 12 + int(namesz) + descStart := align4(nameEnd) + descEnd := descStart + int(descsz) + if descEnd > len(data) || nameEnd > len(data) { + return "" + } + if ntype == ntGNUBuildID && descsz > 0 { + return hex.EncodeToString(data[descStart:descEnd]) + } + // Each note record is at least 12 bytes (header), so this always advances. + data = data[align4(descEnd):] + } + return "" +} + +func align4(n int) int { return (n + 3) &^ 3 } + +// scope is one level of the DWARF DIE tree during the depth-first walk (see the +// Apple builder for the shared shape). fn is the nearest enclosing concrete +// function; inlineDepth counts inlined_subroutine ancestors. +type scope struct { + fn *dsymmap.Function + inlineDepth uint32 +} + +// populate walks the DWARF DIE tree into b: physical functions, their line +// tables, and inlined-call chains. Mirrors apple.populate but on the standard +// library's debug/dwarf types. +func populate(d *dwarf.Data, 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() + + switch ent.Tag { + case dwarf.TagCompileUnit: + curFiles = filesForCU(d, ent) + addLines(d, ent, b) + + case dwarf.TagSubprogram: + if fn := makeFunction(d, ent); 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, depth, curFiles)...) + } + push.inlineDepth = depth + } + + if ent.Children { + stack = append(stack, push) + } + } + + for _, fp := range funcs { + b.Funcs = append(b.Funcs, *fp) + } + return nil +} + +func makeFunction(d *dwarf.Data, ent *dwarf.Entry) *dsymmap.Function { + lo, hi, ok := spanOf(d, ent) + if !ok { + return nil + } + return &dsymmap.Function{Start: lo, End: hi, Name: entryName(d, ent)} +} + +func makeInlines(d *dwarf.Data, ent *dwarf.Entry, 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 { + if rng[1] <= rng[0] { + continue + } + out = append(out, dsymmap.Inline{ + Start: rng[0], + End: rng[1], + Name: name, + CallFile: callFile, + CallLine: callLine, + Depth: depth, + }) + } + return out +} + +// spanOf returns the [low,high) covering all of an entry's PC ranges. +func spanOf(d *dwarf.Data, ent *dwarf.Entry) (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] + } + } + if hi <= lo { + return 0, 0, false + } + return lo, hi, true +} + +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, 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.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: le.Address}) + continue + } + file := "" + if le.File != nil { + file = le.File.Name + } + b.Lines = append(b.Lines, dsymmap.LineRow{Addr: le.Address, File: file, Line: uint32(le.Line)}) + } +} + +func filesForCU(d *dwarf.Data, cu *dwarf.Entry) []*dwarf.LineFile { + lr, err := d.LineReader(cu) + if err != nil || lr == nil { + return nil + } + return lr.Files() +} + +// entryName resolves a subprogram/inlined_subroutine name, following +// abstract_origin/specification references. Dart DWARF names are already +// readable, so no demangling is applied. +func entryName(d *dwarf.Data, ent *dwarf.Entry) string { + if name, _ := ent.Val(dwarf.AttrName).(string); name != "" { + return name + } + if off, ok := refOffset(ent); ok { + if name := resolveName(d, off, 0); name != "" { + return name + } + } + return "" +} + +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) string { + if depth > 4 { + return "" + } + r := d.Reader() + r.Seek(off) + ent, err := r.Next() + if err != nil || ent == nil { + return "" + } + if name, _ := ent.Val(dwarf.AttrName).(string); name != "" { + return name + } + if next, ok := refOffset(ent); ok { + return resolveName(d, next, depth+1) + } + return "" +} diff --git a/internal/symbols/flutter/elf_test.go b/internal/symbols/flutter/elf_test.go new file mode 100644 index 00000000..01ebc8df --- /dev/null +++ b/internal/symbols/flutter/elf_test.go @@ -0,0 +1,61 @@ +package flutter + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" +) + +// note builds a single ELF note record: namesz descsz type, then the padded +// name and descriptor, matching what buildIDFromNotes parses. +func note(bo binary.ByteOrder, name string, ntype uint32, desc []byte) []byte { + nameB := append([]byte(name), 0) // NUL-terminated + var b []byte + hdr := make([]byte, 12) + bo.PutUint32(hdr[0:], uint32(len(nameB))) + bo.PutUint32(hdr[4:], uint32(len(desc))) + bo.PutUint32(hdr[8:], ntype) + b = append(b, hdr...) + b = append(b, nameB...) + for len(b)%4 != 0 { + b = append(b, 0) + } + b = append(b, desc...) + for len(b)%4 != 0 { + b = append(b, 0) + } + return b +} + +func TestBuildIDFromNotes(t *testing.T) { + bo := binary.LittleEndian + id := []byte{0x0f, 0x8a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60} + + // A GNU build-id note on its own. + data := note(bo, "GNU", ntGNUBuildID, id) + assert.Equal(t, "0f8a1b2c3d4e5f60", buildIDFromNotes(data, bo)) + + // Preceded by an unrelated note (e.g. NT_GNU_ABI_TAG=1): must skip and find it. + data = append(note(bo, "GNU", 1, []byte{0, 0, 0, 0, 1, 2, 3, 4}), note(bo, "GNU", ntGNUBuildID, id)...) + assert.Equal(t, "0f8a1b2c3d4e5f60", buildIDFromNotes(data, bo)) + + // No build-id note present. + assert.Equal(t, "", buildIDFromNotes(note(bo, "GNU", 1, []byte{1, 2, 3, 4}), bo)) + + // Truncated data must not panic and must return "". + assert.Equal(t, "", buildIDFromNotes([]byte{1, 2, 3}, bo)) + + // Big-endian is honored. + be := binary.BigEndian + assert.Equal(t, "0f8a1b2c3d4e5f60", buildIDFromNotes(note(be, "GNU", ntGNUBuildID, id), be)) +} + +func TestPlatformFromFilename(t *testing.T) { + assert.Equal(t, "android-arm64", platformFromFilename("app.android-arm64.symbols")) + assert.Equal(t, "ios-arm64", platformFromFilename("build/symbols/app.ios-arm64.symbols")) + assert.Equal(t, "android-x64", platformFromFilename("app.android-x64.symbols")) + // Names that don't match the app..symbols shape yield "". + assert.Equal(t, "", platformFromFilename("app..symbols")) + assert.Equal(t, "", platformFromFilename("mapping.txt")) +} From beb4d8e5db0847634fb25b6779164b1c69936f1e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 24 Jul 2026 10:23:42 -0700 Subject: [PATCH 7/9] flutter more fixes --- cmd/symbols/flutter_upload.go | 63 +++++++++++++++++++++++++-------- internal/symbols/flutter/elf.go | 10 ++++-- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/cmd/symbols/flutter_upload.go b/cmd/symbols/flutter_upload.go index 789c67ee..950241f9 100644 --- a/cmd/symbols/flutter_upload.go +++ b/cmd/symbols/flutter_upload.go @@ -88,40 +88,75 @@ func buildFlutterMaps(path, appVersion string) ([]flutterUpload, error) { } var uploads []flutterUpload - seen := make(map[string]bool) + seenID := make(map[string]bool) + seenVersionKey := make(map[string]bool) + var noBuildID []string for _, file := range files { img, err := flutter.BuildFromELF(file) if err != nil { return nil, fmt.Errorf("failed to process %s: %w", file, err) } - if img.SymbolsID == "" { - return nil, fmt.Errorf("no build id found in %s", file) - } - if seen[img.SymbolsID] { - continue - } - seen[img.SymbolsID] = true var buf bytes.Buffer if err := img.Builder.Encode(&buf); err != nil { - return nil, fmt.Errorf("failed to encode symbol map for %s: %w", img.SymbolsID, err) + return nil, fmt.Errorf("failed to encode symbol map for %s: %w", file, err) } data := buf.Bytes() + // iOS/macOS .symbols files carry no Dart build id (see readBuildID), so the + // Id lane can't be keyed from the file. Fall back to the Version lane, which + // the backend also tries for Flutter crashes (keyed by app version + + // platform). This requires --app-version and a platform token. + if img.SymbolsID == "" { + if appVersion == "" || img.Platform == "" { + noBuildID = append(noBuildID, file) + fmt.Printf("Skipping %s: no build id in file (e.g. iOS .symbols). Re-run with --app-version to upload it to the Version lane.\n", filepath.Base(file)) + continue + } + vKey := flutterVersionKey(appVersion, img.Platform) + if seenVersionKey[vKey] { + continue + } + seenVersionKey[vKey] = true + uploads = append(uploads, flutterUpload{ + Data: data, + Key: vKey, + Label: fmt.Sprintf("%s (Version Lane, no build id)", img.Platform), + }) + fmt.Printf("Built symbol map for %s (Version lane only, no build id, %d bytes)\n", img.Platform, len(data)) + continue + } + + if seenID[img.SymbolsID] { + continue + } + seenID[img.SymbolsID] = true + uploads = append(uploads, flutterUpload{ Data: data, Key: flutterIDKey(img.SymbolsID), Label: fmt.Sprintf("%s (%s, Id Lane)", img.SymbolsID, img.Platform), }) if appVersion != "" && img.Platform != "" { - uploads = append(uploads, flutterUpload{ - Data: data, - Key: flutterVersionKey(appVersion, img.Platform), - Label: fmt.Sprintf("%s (%s, Version Lane)", img.SymbolsID, img.Platform), - }) + vKey := flutterVersionKey(appVersion, img.Platform) + if !seenVersionKey[vKey] { + seenVersionKey[vKey] = true + uploads = append(uploads, flutterUpload{ + Data: data, + Key: vKey, + Label: fmt.Sprintf("%s (%s, Version Lane)", img.SymbolsID, img.Platform), + }) + } } fmt.Printf("Built symbol map for %s (%s, %d bytes)\n", img.SymbolsID, img.Platform, len(data)) } + + if len(uploads) == 0 { + if len(noBuildID) > 0 { + return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) and no --app-version was given, so none could be uploaded. Re-run with --app-version to use the Version lane", len(noBuildID)) + } + return nil, fmt.Errorf("no Flutter symbol maps could be built from %s", path) + } return uploads, nil } diff --git a/internal/symbols/flutter/elf.go b/internal/symbols/flutter/elf.go index f2d74ecb..5097ce1a 100644 --- a/internal/symbols/flutter/elf.go +++ b/internal/symbols/flutter/elf.go @@ -93,7 +93,11 @@ func platformFromFilename(path string) string { } // readBuildID returns the GNU build id of an ELF as lowercase hex, scanning both -// note sections and PT_NOTE segments (Dart emits it as a .note.gnu.build-id). +// note sections and PT_NOTE segments (Dart emits it as a .note.gnu.build-id on +// ELF targets such as Android/Linux). iOS/macOS .symbols files are ELF but carry +// no build-id note (the snapshot ships in a Mach-O App.framework), so this +// returns "" (not an error) there; the caller then falls back to the Version +// lane, which keys by app version + platform instead of the build id. func readBuildID(f *elf.File) (string, error) { for _, sec := range f.Sections { if sec.Type != elf.SHT_NOTE { @@ -119,7 +123,9 @@ func readBuildID(f *elf.File) (string, error) { return id, nil } } - return "", e.New("no GNU build-id note found") + // No build-id note (e.g. iOS/macOS .symbols): not an error — the caller uses + // the Version lane instead. + return "", nil } // buildIDFromNotes walks a note section/segment (a sequence of ELF notes) and From a0abe1bddcf5c52ac5bc6d1745009ea86e04b26e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 24 Jul 2026 11:24:03 -0700 Subject: [PATCH 8/9] fix --- cmd/symbols/flutter_upload.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cmd/symbols/flutter_upload.go b/cmd/symbols/flutter_upload.go index 950241f9..10cdb318 100644 --- a/cmd/symbols/flutter_upload.go +++ b/cmd/symbols/flutter_upload.go @@ -91,6 +91,7 @@ func buildFlutterMaps(path, appVersion string) ([]flutterUpload, error) { seenID := make(map[string]bool) seenVersionKey := make(map[string]bool) var noBuildID []string + var missingAppVersion, missingPlatform int for _, file := range files { img, err := flutter.BuildFromELF(file) if err != nil { @@ -110,7 +111,13 @@ func buildFlutterMaps(path, appVersion string) ([]flutterUpload, error) { if img.SymbolsID == "" { if appVersion == "" || img.Platform == "" { noBuildID = append(noBuildID, file) - fmt.Printf("Skipping %s: no build id in file (e.g. iOS .symbols). Re-run with --app-version to upload it to the Version lane.\n", filepath.Base(file)) + if appVersion == "" { + missingAppVersion++ + fmt.Printf("Skipping %s: no build id in file (e.g. iOS .symbols). Re-run with --app-version to upload it to the Version lane.\n", filepath.Base(file)) + } else { + missingPlatform++ + fmt.Printf("Skipping %s: no build id in file and no platform token could be parsed from the filename (expected app..symbols), so the Version lane can't be keyed.\n", filepath.Base(file)) + } continue } vKey := flutterVersionKey(appVersion, img.Platform) @@ -153,7 +160,14 @@ func buildFlutterMaps(path, appVersion string) ([]flutterUpload, error) { if len(uploads) == 0 { if len(noBuildID) > 0 { - return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) and no --app-version was given, so none could be uploaded. Re-run with --app-version to use the Version lane", len(noBuildID)) + switch { + case missingAppVersion > 0 && missingPlatform > 0: + return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) that could not be uploaded: %d were missing --app-version, and %d had no platform token parsable from the filename (expected app..symbols). Re-run with --app-version and ensure filenames include a platform token to use the Version lane", len(noBuildID), missingAppVersion, missingPlatform) + case missingPlatform > 0: + return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) and no platform token parsable from the filename (expected app..symbols), so the Version lane could not be keyed and none could be uploaded", len(noBuildID)) + default: + return nil, fmt.Errorf("found %d Flutter symbol file(s) with no build id (e.g. iOS .symbols) and no --app-version was given, so none could be uploaded. Re-run with --app-version to use the Version lane", len(noBuildID)) + } } return nil, fmt.Errorf("no Flutter symbol maps could be built from %s", path) } From 630dda1599538eb7e5b40eddf152a935b9a62cdd Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 24 Jul 2026 12:14:11 -0700 Subject: [PATCH 9/9] fix --- internal/symbols/flutter/elf.go | 8 +++++++- internal/symbols/flutter/elf_test.go | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/symbols/flutter/elf.go b/internal/symbols/flutter/elf.go index 5097ce1a..2173c8d6 100644 --- a/internal/symbols/flutter/elf.go +++ b/internal/symbols/flutter/elf.go @@ -146,8 +146,14 @@ func buildIDFromNotes(data []byte, bo binary.ByteOrder) string { if ntype == ntGNUBuildID && descsz > 0 { return hex.EncodeToString(data[descStart:descEnd]) } + // The descriptor's padding may run past the end when the note is the last + // one and the section isn't 4-byte aligned; there is nothing left to read. + next := align4(descEnd) + if next > len(data) { + return "" + } // Each note record is at least 12 bytes (header), so this always advances. - data = data[align4(descEnd):] + data = data[next:] } return "" } diff --git a/internal/symbols/flutter/elf_test.go b/internal/symbols/flutter/elf_test.go index 01ebc8df..87fac629 100644 --- a/internal/symbols/flutter/elf_test.go +++ b/internal/symbols/flutter/elf_test.go @@ -46,6 +46,11 @@ func TestBuildIDFromNotes(t *testing.T) { // Truncated data must not panic and must return "". assert.Equal(t, "", buildIDFromNotes([]byte{1, 2, 3}, bo)) + // A skipped note whose descriptor ends at the very end of the data but whose + // padding would run past it must not panic. + unaligned := note(bo, "GNU", 1, []byte{1, 2, 3}) + assert.Equal(t, "", buildIDFromNotes(unaligned[:len(unaligned)-1], bo)) + // Big-endian is honored. be := binary.BigEndian assert.Equal(t, "0f8a1b2c3d4e5f60", buildIDFromNotes(note(be, "GNU", ntGNUBuildID, id), be))