Skip to content
Open
83 changes: 83 additions & 0 deletions cmd/symbols/apple_sources.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package symbols

import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/launchdarkly/ldcli/internal/symbols/apple"
"github.com/launchdarkly/ldcli/internal/symbols/srcbundle"
)

// appleSourceExt is appended to the build UUID to form the source-bundle object
// name, so an image's map and its sources sit side by side under the same key:
// _sym/apple/id/<UUID>.dsymmap and _sym/apple/id/<UUID>.srcbundle.
const appleSourceExt = ".srcbundle"

const (
// maxSourceFileBytes skips individual files too large to be worth shipping
// (generated code, vendored blobs). Only a handful of lines around a frame is
// ever rendered, so a huge file adds size for no benefit.
maxSourceFileBytes = 2 << 20 // 2 MiB

// maxSourceBundleBytes caps one image's uncompressed source total, so an
// accidental `--include-sources` over a huge workspace can't produce an
// unbounded upload.
maxSourceBundleBytes = 256 << 20 // 256 MiB
)

// appleSourceExtensions are the file types the UI can render as source context.
// Restricting to these keeps unrelated DWARF-referenced paths (module maps,
// generated interfaces) out of the bundle.
var appleSourceExtensions = map[string]bool{
".swift": true,
".m": true,
".mm": true,
".h": true,
".hpp": true,
".hh": true,
".c": true,
".cc": true,
".cpp": true,
".cxx": true,
}

// buildAppleSourceBundle packs the source files referenced by one architecture's
// DWARF into a .srcbundle. Files that aren't readable on this machine (SDK,
// system, or third-party code built elsewhere) are skipped — the backend simply
// renders those frames without source context. Returns nil when nothing local
// was found, so the caller can skip the upload entirely.
func buildAppleSourceBundle(a apple.Arch) ([]byte, int, error) {
b := &srcbundle.Builder{}
total := 0
for key, path := range a.Sources {
if !appleSourceExtensions[strings.ToLower(filepath.Ext(key))] {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue // not on this machine: expected for SDK/system sources
}
if len(data) > maxSourceFileBytes || total+len(data) > maxSourceBundleBytes {
continue
}
total += len(data)
b.Add(key, data)
}
if b.Len() == 0 {
return nil, 0, nil
}

var buf bytes.Buffer
if err := b.Encode(&buf); err != nil {
return nil, 0, fmt.Errorf("failed to encode source bundle for %s: %w", a.UUID, err)
}
return buf.Bytes(), b.Len(), nil
}

// appleSourceKey is the storage key for an image's source bundle.
func appleSourceKey(uuid string) string {
return fmt.Sprintf("%s/%s%s", appleSymbolsIDPrefix, uuid, appleSourceExt)
}
135 changes: 135 additions & 0 deletions cmd/symbols/apple_sources_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package symbols

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/launchdarkly/ldcli/internal/symbols/apple"
"github.com/launchdarkly/ldcli/internal/symbols/srcbundle"
)

func TestAppleSourceKey(t *testing.T) {
key := appleSourceKey("A5121984B70C3CA0BCC22FB671D75A20")
assert.Equal(t, "_sym/apple/id/A5121984B70C3CA0BCC22FB671D75A20.srcbundle", key)
// The source bundle sits beside the map under the same UUID.
assert.Equal(t, strings.TrimSuffix(appleKey("A5121984B70C3CA0BCC22FB671D75A20"), appleSymbolExt),
strings.TrimSuffix(key, appleSourceExt))
}

// buildAppleSourceBundle packs readable sources keyed exactly as the .dsymmap
// spells them, so a resolved frame's FileName is the lookup key.
func TestBuildAppleSourceBundle(t *testing.T) {
dir := t.TempDir()
swiftPath := filepath.Join(dir, "Cart.swift")
require.NoError(t, os.WriteFile(swiftPath, []byte("import Foundation\nlet total = 1\n"), 0o644))

a := apple.Arch{
UUID: "A5121984B70C3CA0BCC22FB671D75A20",
Sources: map[string]string{"/build/Sources/Cart.swift": swiftPath},
}
data, nFiles, err := buildAppleSourceBundle(a)
require.NoError(t, err)
require.NotNil(t, data)
assert.Equal(t, 1, nFiles)

bundle, err := srcbundle.Open(data)
require.NoError(t, err)
// Keyed by the DWARF path, not the local path it was read from.
_, content, _, ok := bundle.Window("/build/Sources/Cart.swift", 2, 2)
require.True(t, ok)
assert.Equal(t, "let total = 1\n", content)

_, ok = bundle.File(swiftPath)
assert.False(t, ok, "local path must not be a key")
}

func TestBuildAppleSourceBundleSkipsUnreadableAndNonSource(t *testing.T) {
dir := t.TempDir()
txtPath := filepath.Join(dir, "notes.txt")
require.NoError(t, os.WriteFile(txtPath, []byte("not source\n"), 0o644))

a := apple.Arch{
UUID: "A5121984B70C3CA0BCC22FB671D75A20",
Sources: map[string]string{
"/build/notes.txt": txtPath, // wrong extension
"/build/Gone.swift": filepath.Join(dir, "Gone.swift"), // not on this machine
"/SDK/Vendor.swift": filepath.Join(dir, "nope.swift"), // not on this machine
"/build/Modules/module.map": filepath.Join(dir, "module.map"), // wrong extension
},
}
data, nFiles, err := buildAppleSourceBundle(a)
require.NoError(t, err)
assert.Nil(t, data, "nothing bundleable yields no bundle")
assert.Zero(t, nFiles)
}

func TestBuildAppleSourceBundleSkipsOversizeFile(t *testing.T) {
dir := t.TempDir()
big := filepath.Join(dir, "Generated.swift")
require.NoError(t, os.WriteFile(big, make([]byte, maxSourceFileBytes+1), 0o644))
small := filepath.Join(dir, "Small.swift")
require.NoError(t, os.WriteFile(small, []byte("let a = 1\n"), 0o644))

a := apple.Arch{
UUID: "A5121984B70C3CA0BCC22FB671D75A20",
Sources: map[string]string{
"/build/Generated.swift": big,
"/build/Small.swift": small,
},
}
data, nFiles, err := buildAppleSourceBundle(a)
require.NoError(t, err)
require.NotNil(t, data)
assert.Equal(t, 1, nFiles, "oversize file is skipped")

bundle, err := srcbundle.Open(data)
require.NoError(t, err)
_, ok := bundle.File("/build/Small.swift")
assert.True(t, ok)
_, ok = bundle.File("/build/Generated.swift")
assert.False(t, ok)
}

// The fixture dSYM's sources aren't checked out on this machine, so
// --include-sources must degrade to "map only" instead of failing.
func TestBuildAppleMapsWithSourcesDoesNotFail(t *testing.T) {
image := filepath.Join(fixtureDSYM, "Contents", "Resources", "DWARF", "symbolsdemo")
maps, err := buildAppleMaps([]string{image}, true)
require.NoError(t, err)
require.NotEmpty(t, maps)

for _, m := range maps {
if m.Kind == "sources" {
assert.Equal(t, appleSourceKey(m.UUID), m.Key)
_, err := srcbundle.Open(m.Data)
require.NoError(t, err, "uploaded source bundle must decode")
} else {
assert.Equal(t, appleKey(m.UUID), m.Key)
}
}
}

// The DWARF walk must record the source paths the map references.
func TestBuildFromMachOCollectsSources(t *testing.T) {
image := filepath.Join(fixtureDSYM, "Contents", "Resources", "DWARF", "symbolsdemo")
arches, err := apple.BuildFromMachO(image)
require.NoError(t, err)
require.NotEmpty(t, arches)

found := false
for _, a := range arches {
if len(a.Sources) > 0 {
found = true
for key, abs := range a.Sources {
assert.NotEmpty(t, key)
assert.True(t, filepath.IsAbs(abs), "%s should resolve to an absolute path", key)
}
}
}
assert.True(t, found, "fixture DWARF should reference at least one source file")
}
49 changes: 44 additions & 5 deletions cmd/symbols/apple_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,31 @@ const appleSymbolsIDPrefix = "_sym/apple/id"
// 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.
// appleSymbolMap is one compiled artifact ready to upload: an architecture's
// .dsymmap symbol map, or (with --include-sources) its .srcbundle sources.
type appleSymbolMap struct {
Key string
UUID string
Arch string
Data []byte
// Kind labels the artifact in progress output ("" for a symbol map).
Kind string
}

// label describes the artifact for upload logs.
func (m appleSymbolMap) label() string {
if m.Kind == "" {
return fmt.Sprintf("%s (%s)", m.UUID, m.Arch)
}
return fmt.Sprintf("%s (%s, %s)", m.UUID, m.Arch, m.Kind)
}

// uploadAppleDSYMs discovers .dSYM bundles under path, compiles each contained
// architecture to a .dsymmap symbol map, and uploads one object per build UUID.
func uploadAppleDSYMs(apiKey, projectID, path, backendURL string) error {
// When includeSources is set, each image's referenced source files are packed
// into a .srcbundle and uploaded alongside its map so the UI can show source
// context around native frames.
func uploadAppleDSYMs(apiKey, projectID, path, backendURL string, includeSources bool) error {
images, err := findDSYMImages(path)
if err != nil {
return fmt.Errorf("failed to find dSYM files: %w", err)
Expand All @@ -41,7 +55,7 @@ func uploadAppleDSYMs(apiKey, projectID, path, backendURL string) error {
return fmt.Errorf("no .dSYM bundles found in %s, is this the correct path?", path)
}

maps, err := buildAppleMaps(images)
maps, err := buildAppleMaps(images, includeSources)
if err != nil {
return err
}
Expand All @@ -65,7 +79,7 @@ func uploadAppleDSYMs(apiKey, projectID, path, backendURL string) error {
}

for i, m := range maps {
if err := uploadBytes(m.Data, uploadURLs[i], fmt.Sprintf("%s (%s)", m.UUID, m.Arch)); err != nil {
if err := uploadBytes(m.Data, uploadURLs[i], m.label()); err != nil {
return fmt.Errorf("failed to upload symbol map for %s: %w", m.UUID, err)
}
}
Expand All @@ -76,7 +90,9 @@ func uploadAppleDSYMs(apiKey, projectID, path, backendURL string) error {

// 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) {
// With includeSources it also emits a .srcbundle per image, keyed by the same
// UUID.
func buildAppleMaps(images []string, includeSources bool) ([]appleSymbolMap, error) {
var maps []appleSymbolMap
seen := make(map[string]bool)

Expand All @@ -103,6 +119,29 @@ func buildAppleMaps(images []string) ([]appleSymbolMap, error) {
Data: buf.Bytes(),
})
fmt.Printf("Built symbol map for %s (%s, %d bytes)\n", a.UUID, arch, buf.Len())

if !includeSources {
continue
}
srcData, nFiles, err := buildAppleSourceBundle(a)
if err != nil {
return nil, err
}
if srcData == nil {
// None of the DWARF-referenced sources are readable here (e.g.
// uploading a dSYM archived on another machine). Not fatal: the map
// alone still symbolicates, just without source context.
fmt.Printf("No local sources found for %s (%s); skipping source bundle\n", a.UUID, arch)
continue
}
maps = append(maps, appleSymbolMap{
Key: appleSourceKey(a.UUID),
UUID: a.UUID,
Arch: arch,
Data: srcData,
Kind: "sources",
})
fmt.Printf("Built source bundle for %s (%s, %d files, %d bytes)\n", a.UUID, arch, nFiles, len(srcData))
}
}
return maps, nil
Expand Down
4 changes: 2 additions & 2 deletions cmd/symbols/apple_upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestFindDSYMImages_Missing(t *testing.T) {
// 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})
maps, err := buildAppleMaps([]string{image}, false)
require.NoError(t, err)
require.Len(t, maps, 2, "universal fixture yields arm64 + x86_64 maps")

Expand All @@ -81,7 +81,7 @@ func TestBuildAppleMaps(t *testing.T) {
// produce duplicate uploads.
func TestBuildAppleMaps_DedupesUUID(t *testing.T) {
image := filepath.Join(fixtureDSYM, "Contents", "Resources", "DWARF", "symbolsdemo")
maps, err := buildAppleMaps([]string{image, image})
maps, err := buildAppleMaps([]string{image, image}, false)
require.NoError(t, err)
assert.Len(t, maps, 2, "duplicate images must be deduplicated by UUID")
}
Expand Down
Loading
Loading