Files
mandiant-GoReSym/objfile/strings_test.go
T
kamran ul haq 0ead0ced52 Add lint and test jobs to CI pipeline (#82)
* Add lint and test jobs to CI pipeline

Addresses issue #81 — current CI only checks compilation, no tests run.

- lint (ubuntu-latest): gofmt -l and go vet ./... using default Go
  tooling only, no third-party linters per maintainer guidance

- test (ubuntu-latest): runs build_test_files.sh via Docker to generate
  all 18 Go version binaries (stripped/non-stripped x lin/win/mac x
  32/64-bit), then go test ./... covering TestAllVersions,
  TestWeirdBins, and TestExtractStrings_CompareWithFLOSS

- build (windows-latest): existing release job unchanged

* Fix CI failures: gofmt, missing test binary, and add Go 1.23/1.24

* Add Go 1.23/1.24 to test matrix

- Add Go 1.23 and 1.24 to versions array in both build_test_files.sh
  and main_test.go — all 10 binary variants pass for both versions
2026-02-25 18:13:39 -05:00

345 lines
10 KiB
Go

package objfile
import (
"path/filepath"
"testing"
)
// TestExtractStrings_ELF tests string extraction from an ELF binary (Linux)
func TestExtractStrings_ELF(t *testing.T) {
testBinary := filepath.Join("..", "testproject", "testproject")
file, err := Open(testBinary)
if err != nil {
t.Skipf("Could not open test binary: %v (run: cd testproject && go build)", err)
}
defer file.Close()
strings, err := file.ExtractStrings()
if err != nil {
t.Fatalf("ExtractStrings() failed: %v", err)
}
// Should extract a reasonable number of Go strings
if len(strings) < 100 {
t.Errorf("Expected at least 100 strings from ELF binary, got %d", len(strings))
}
// Verify common Go type/keyword strings are present
expectedStrings := []string{"func", "chan", "bool", "uint"}
assertStringsPresent(t, strings, expectedStrings)
}
// TestExtractStrings_PE tests string extraction from a PE binary (Windows)
func TestExtractStrings_PE(t *testing.T) {
testBinary := filepath.Join("..", "testproject", "testproject.exe")
file, err := Open(testBinary)
if err != nil {
t.Skipf("Could not open Windows test binary: %v (run: cd testproject && GOOS=windows GOARCH=amd64 go build -o testproject.exe)", err)
}
defer file.Close()
strings, err := file.ExtractStrings()
if err != nil {
t.Fatalf("ExtractStrings() failed: %v", err)
}
// Should extract a reasonable number of Go strings
if len(strings) < 100 {
t.Errorf("Expected at least 100 strings from PE binary, got %d", len(strings))
}
// Verify common Go type/keyword strings are present
expectedStrings := []string{"func", "chan", "bool", "uint"}
assertStringsPresent(t, strings, expectedStrings)
}
// TestFindLongestMonotonicRun validates the core algorithm
// Note: function requires at least 10 entries for a valid run (practical filter)
func TestFindLongestMonotonicRun(t *testing.T) {
t.Run("finds monotonic run in realistic data", func(t *testing.T) {
// Create realistic test data: monotonically increasing lengths
candidates := make([]StringCandidate, 50)
for i := range candidates {
candidates[i] = StringCandidate{
Pointer: uint64(0x1000 + i*10),
Length: uint64(i + 4), // lengths 4, 5, 6, ... 53
}
}
start, end := findLongestMonotonicRun(candidates)
// Should find the entire sequence as one run
if start == -1 || end == -1 {
t.Error("Expected to find a valid run, got (-1, -1)")
}
runLength := end - start + 1
if runLength < 10 {
t.Errorf("Expected run length >= 10, got %d", runLength)
}
})
t.Run("too short returns -1", func(t *testing.T) {
candidates := []StringCandidate{
{Length: 1}, {Length: 2}, {Length: 3}, // Only 3 entries
}
start, end := findLongestMonotonicRun(candidates)
if start != -1 || end != -1 {
t.Errorf("Expected (-1, -1) for short input, got (%d, %d)", start, end)
}
})
}
// TestIsFullyPrintable validates the printability filter.
// All characters must be printable (unicode.IsPrint) or common whitespace (\t, \n, \r).
func TestIsFullyPrintable(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"hello world", true},
{"runtime.error", true},
{"line1\nline2", true}, // newlines are allowed
{"col1\tcol2", true}, // tabs are allowed
{"windows\r\n", true}, // carriage return allowed
{"\x00\x01\x02\x03", false}, // all non-printable
{"abc\x00\x01", false}, // any non-printable fails (was 80% threshold)
{"mostly ok \x01", false}, // even one non-printable byte fails
{"", false}, // empty string
}
for _, tt := range tests {
got := isFullyPrintable(tt.input)
if got != tt.want {
t.Errorf("isFullyPrintable(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
// TestExtractStrings_MinLength validates minimum length filtering
func TestExtractStrings_MinLength(t *testing.T) {
testBinary := filepath.Join("..", "testproject", "testproject")
file, err := Open(testBinary)
if err != nil {
t.Skip("Test binary not available")
}
defer file.Close()
strings, err := file.ExtractStrings()
if err != nil {
t.Fatalf("ExtractStrings() failed: %v", err)
}
// All strings must be >= 4 characters (MIN_STRING_LENGTH)
for _, s := range strings {
if len(s) < 4 {
t.Errorf("Found string shorter than 4 chars: %q (len=%d)", s, len(s))
}
}
}
// TestFindLongestMonotonicRun_MixedData tests that the algorithm correctly
// finds the blob candidates among noise. This simulates address-sorted
// candidates where only the middle portion points into the string blob.
func TestFindLongestMonotonicRun_MixedData(t *testing.T) {
// Simulate: 5 noise candidates, then 20 blob candidates, then 5 noise
var candidates []StringCandidate
// Noise before blob (random lengths, not monotonic)
noise1 := []uint64{50, 3, 100, 7, 42}
for i, l := range noise1 {
candidates = append(candidates, StringCandidate{
Pointer: uint64(0x1000 + i*8),
Length: l,
})
}
// String blob candidates: sorted by address, monotonically increasing lengths
// This is what Go's string internment table looks like
for i := 0; i < 20; i++ {
candidates = append(candidates, StringCandidate{
Pointer: uint64(0x4000 + i*10),
Length: uint64(4 + i), // 4, 5, 6, ..., 23
})
}
// Noise after blob
noise2 := []uint64{2, 80, 1, 60, 5}
for i, l := range noise2 {
candidates = append(candidates, StringCandidate{
Pointer: uint64(0x8000 + i*8),
Length: l,
})
}
start, end := findLongestMonotonicRun(candidates)
if start == -1 || end == -1 {
t.Fatal("Expected to find a valid run, got (-1, -1)")
}
runLength := end - start + 1
if runLength != 20 {
t.Errorf("Expected run length 20 (the blob candidates), got %d", runLength)
}
// The run should start at index 5 (after the 5 noise entries)
if start != 5 {
t.Errorf("Expected run to start at index 5, got %d", start)
}
}
// TestDeduplicateUint64 validates the deduplication helper
func TestDeduplicateUint64(t *testing.T) {
tests := []struct {
name string
input []uint64
expect []uint64
}{
{"no duplicates", []uint64{1, 2, 3}, []uint64{1, 2, 3}},
{"with duplicates", []uint64{1, 1, 2, 3, 3, 3, 4}, []uint64{1, 2, 3, 4}},
{"all same", []uint64{5, 5, 5}, []uint64{5}},
{"single element", []uint64{42}, []uint64{42}},
{"empty", []uint64{}, []uint64{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := deduplicateUint64(tt.input)
if len(got) != len(tt.expect) {
t.Errorf("deduplicateUint64(%v) returned %d elements, want %d", tt.input, len(got), len(tt.expect))
return
}
for i := range got {
if got[i] != tt.expect[i] {
t.Errorf("deduplicateUint64(%v)[%d] = %d, want %d", tt.input, i, got[i], tt.expect[i])
}
}
})
}
}
// TestIsDataSection validates section name matching across ELF/PE/Mach-O formats
func TestIsDataSection(t *testing.T) {
tests := []struct {
name string
want bool
}{
// ELF sections
{".rodata", true},
{".data", true},
{".noptrdata", true},
// Mach-O sections
{"__rodata", true},
{"__data", true},
{"__noptrdata", true},
// PE sections
{".rdata", true},
{".text", true}, // Included for old Go Windows binaries (1.7-1.10) that store strings in .text
// Suffixed variants (some formats append .__)
{".rodata.__", true},
// Non-data sections
{".bss", false},
{"__TEXT", false},
{".got", false},
}
for _, tt := range tests {
got := isDataSection(tt.name)
if got != tt.want {
t.Errorf("isDataSection(%q) = %v, want %v", tt.name, got, tt.want)
}
}
}
// TestFindStringCandidates validates candidate extraction from raw binary data
func TestFindStringCandidates(t *testing.T) {
t.Run("64-bit little-endian", func(t *testing.T) {
// Create a fake section with one string struct:
// struct: pointer=0x5000, length=5
// Scanner steps at ptrSize (8-byte) alignment, so we use exactly
// one 16-byte struct to avoid overlapping false positives.
data := make([]byte, 16)
// pointer = 0x5000 (little-endian uint64)
data[0] = 0x00
data[1] = 0x50
data[2] = 0x00
data[3] = 0x00
data[4] = 0x00
data[5] = 0x00
data[6] = 0x00
data[7] = 0x00
// length = 5 (little-endian uint64)
data[8] = 0x05
data[9] = 0x00
data[10] = 0x00
data[11] = 0x00
data[12] = 0x00
data[13] = 0x00
data[14] = 0x00
data[15] = 0x00
// imageMin=0x1000, imageMax=0x10000 (pointer 0x5000 is in range), maxSectionSize=1000
candidates := findStringCandidates(data, 0x1000, true, true, 0x1000, 0x10000, 1000)
if len(candidates) != 1 {
t.Fatalf("Expected 1 candidate, got %d", len(candidates))
}
if candidates[0].Pointer != 0x5000 || candidates[0].Length != 5 {
t.Errorf("candidate[0] = {%#x, %d}, want {0x5000, 5}", candidates[0].Pointer, candidates[0].Length)
}
})
t.Run("skips zero pointer and length", func(t *testing.T) {
data := make([]byte, 16)
// pointer=0, length=0 -- should be skipped
candidates := findStringCandidates(data, 0x1000, true, true, 0x1000, 0x10000, 1000)
if len(candidates) != 0 {
t.Errorf("Expected 0 candidates for zero data, got %d", len(candidates))
}
})
t.Run("skips pointer outside image range", func(t *testing.T) {
data := make([]byte, 16)
// pointer = 0x5000 (outside range [0x1000, 0x4000))
data[0] = 0x00
data[1] = 0x50
data[8] = 0x05
candidates := findStringCandidates(data, 0x1000, true, true, 0x1000, 0x4000, 1000)
if len(candidates) != 0 {
t.Errorf("Expected 0 candidates for out-of-range pointer, got %d", len(candidates))
}
})
t.Run("skips length exceeding max section size", func(t *testing.T) {
data := make([]byte, 16)
// pointer = 0x2000 (in range), length = 500 (exceeds maxSectionSize=100)
data[0] = 0x00
data[1] = 0x20
data[8] = 0xF4 // 500 in little-endian
data[9] = 0x01
candidates := findStringCandidates(data, 0x1000, true, true, 0x1000, 0x10000, 100)
if len(candidates) != 0 {
t.Errorf("Expected 0 candidates for oversized length, got %d", len(candidates))
}
})
}
// Helper function to check if expected strings are present
func assertStringsPresent(t *testing.T, strings []string, expected []string) {
t.Helper()
stringSet := make(map[string]bool)
for _, s := range strings {
stringSet[s] = true
}
for _, exp := range expected {
if !stringSet[exp] {
t.Errorf("Expected string %q not found in extracted strings", exp)
}
}
}