diff --git a/cmd/symbols/flutter_upload.go b/cmd/symbols/flutter_upload.go new file mode 100644 index 00000000..10cdb318 --- /dev/null +++ b/cmd/symbols/flutter_upload.go @@ -0,0 +1,225 @@ +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 + 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 { + return nil, fmt.Errorf("failed to process %s: %w", file, err) + } + + var buf bytes.Buffer + if err := img.Builder.Encode(&buf); err != nil { + 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) + 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) + 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 != "" { + 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 { + 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) + } + 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..2173c8d6 --- /dev/null +++ b/internal/symbols/flutter/elf.go @@ -0,0 +1,378 @@ +// 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 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 { + 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 + } + } + // 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 +// 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]) + } + // 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[next:] + } + 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..87fac629 --- /dev/null +++ b/internal/symbols/flutter/elf_test.go @@ -0,0 +1,66 @@ +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)) + + // 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)) +} + +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")) +}