Files
garble/main_test.go
T
Daniel Martí ef2385ee97 internal/literals: restrict the use of expensive obfuscators
The added benchmark script shows these numbers for building and running
with vanilla go on literal sizes between 16B and 2048B,
showing that vanilla Go isn't affected at all by these sizes:

                │      go      │
                │    sec/op    │
    Build/16B     118.1m ± ∞ ¹
    Build/64B     123.1m ± ∞ ¹
    Build/256B    119.7m ± ∞ ¹
    Build/1024B   119.1m ± ∞ ¹
    Build/2048B   124.3m ± ∞ ¹
    Run/16B       1.671m ± ∞ ¹
    Run/64B       1.143m ± ∞ ¹
    Run/256B      1.190m ± ∞ ¹
    Run/1024B     1.222m ± ∞ ¹
    Run/2048B     1.080m ± ∞ ¹

Our simple and swap obfuscators scale pretty well to these same sizes,
only causing moderate slow-downs to build and runtime speeds:

                │                simple                │                 swap                 │
                │    sec/op     vs base                │    sec/op     vs base                │
    Build/16B     268.0m ± ∞ ¹  +126.88% (p=1.000 n=1)   262.0m ± ∞ ¹  +121.80% (p=1.000 n=1)
    Build/64B     253.8m ± ∞ ¹  +106.16% (p=1.000 n=1)   252.4m ± ∞ ¹  +105.00% (p=1.000 n=1)
    Build/256B    265.4m ± ∞ ¹  +121.78% (p=1.000 n=1)   276.7m ± ∞ ¹  +131.16% (p=1.000 n=1)
    Build/1024B   267.4m ± ∞ ¹  +124.44% (p=1.000 n=1)   315.0m ± ∞ ¹  +164.48% (p=1.000 n=1)
    Build/2048B   277.4m ± ∞ ¹  +123.11% (p=1.000 n=1)   383.8m ± ∞ ¹  +208.70% (p=1.000 n=1)
    Run/16B       1.740m ± ∞ ¹    +4.12% (p=1.000 n=1)   1.463m ± ∞ ¹   -12.47% (p=1.000 n=1)
    Run/64B       1.470m ± ∞ ¹   +28.66% (p=1.000 n=1)   1.455m ± ∞ ¹   +27.35% (p=1.000 n=1)
    Run/256B      1.729m ± ∞ ¹   +45.25% (p=1.000 n=1)   1.812m ± ∞ ¹   +52.26% (p=1.000 n=1)
    Run/1024B     1.315m ± ∞ ¹    +7.62% (p=1.000 n=1)   1.352m ± ∞ ¹   +10.60% (p=1.000 n=1)
    Run/2048B     1.425m ± ∞ ¹   +31.93% (p=1.000 n=1)   1.316m ± ∞ ¹   +21.88% (p=1.000 n=1)

However, the other three cause huge slow-downs in both build and runtime speeds:

                │                   split                    │                shuffle                 │                    seed                    │
                │     sec/op       vs base                   │    sec/op      vs base                 │     sec/op       vs base                   │
    Build/16B        326.6m ± ∞ ¹     +176.53% (p=1.000 n=1)    363.1m ± ∞ ¹   +207.44% (p=1.000 n=1)      217.4m ± ∞ ¹      +84.05% (p=1.000 n=1)
    Build/64B        400.6m ± ∞ ¹     +225.34% (p=1.000 n=1)    328.0m ± ∞ ¹   +166.39% (p=1.000 n=1)      262.1m ± ∞ ¹     +112.87% (p=1.000 n=1)
    Build/256B       824.7m ± ∞ ¹     +589.08% (p=1.000 n=1)    588.2m ± ∞ ¹   +391.45% (p=1.000 n=1)      873.7m ± ∞ ¹     +630.05% (p=1.000 n=1)
    Build/1024B     3257.7m ± ∞ ¹    +2634.84% (p=1.000 n=1)   1671.4m ± ∞ ¹  +1303.15% (p=1.000 n=1)     5000.0m ± ∞ ¹    +4097.53% (p=1.000 n=1)
    Build/2048B     5000.0m ± ∞ ¹    +3921.73% (p=1.000 n=1)   3936.4m ± ∞ ¹  +3066.21% (p=1.000 n=1)     5000.0m ± ∞ ¹    +3921.73% (p=1.000 n=1)
    Run/16B          1.680m ± ∞ ¹       +0.51% (p=1.000 n=1)    1.426m ± ∞ ¹    -14.67% (p=1.000 n=1)      1.908m ± ∞ ¹      +14.13% (p=1.000 n=1)
    Run/64B          1.560m ± ∞ ¹      +36.53% (p=1.000 n=1)    1.345m ± ∞ ¹    +17.74% (p=1.000 n=1)      1.704m ± ∞ ¹      +49.10% (p=1.000 n=1)
    Run/256B         2.133m ± ∞ ¹      +79.24% (p=1.000 n=1)    1.833m ± ∞ ¹    +53.98% (p=1.000 n=1)      1.838m ± ∞ ¹      +54.40% (p=1.000 n=1)
    Run/1024B        2.863m ± ∞ ¹     +134.21% (p=1.000 n=1)    1.786m ± ∞ ¹    +46.12% (p=1.000 n=1)   5000.000m ± ∞ ¹  +408975.92% (p=1.000 n=1)
    Run/2048B     5000.000m ± ∞ ¹  +462837.24% (p=1.000 n=1)    2.900m ± ∞ ¹   +168.54% (p=1.000 n=1)   5000.000m ± ∞ ¹  +462837.24% (p=1.000 n=1)

As such, limit the scope of when we apply our obfuscators in two ways.

First, always apply a limit of 2048 bytes for all obfuscators.
As we can see above, the two cheap obfuscators still add some overhead,
and we aren't testing what happens if they run on truly huge strings.
It's likely that they will still cause unexpected slow-down.

Second, split the obfuscators along this line and call them
"cheap" versus "expensive". The expensive ones are only used
for sizes of up to 256 bytes. We still measure moderate slow-downs
in build times of 400-600%, but this is a reasonable compromise for now.
We will be filing bugs with upstream Go about the compiler overhead.

We adjust generateLiterals accordingly;
it now generates 100 literals between MinSize and MaxSize,
which must be obfuscated in some way, and 5 literals past MaxSize,
which don't need to be obfuscated, to check for issues like crashes.

Fixes #928.
2026-04-14 10:32:47 +02:00

497 lines
13 KiB
Go

// Copyright (c) 2019, The Garble Authors.
// See LICENSE for licensing information.
package main
import (
"flag"
"fmt"
"go/ast"
"go/printer"
"go/token"
"io/fs"
mathrand "math/rand"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
"time"
"github.com/go-quicktest/qt"
"github.com/rogpeppe/go-internal/goproxytest"
"github.com/rogpeppe/go-internal/gotooltest"
"github.com/rogpeppe/go-internal/testscript"
ah "mvdan.cc/garble/internal/asthelper"
"mvdan.cc/garble/internal/literals"
)
var proxyURL string
func TestMain(m *testing.M) {
// If GORACE is unset, lower the default of atexit_sleep_ms=1000,
// since otherwise every execution of garble through the test binary
// would sleep for one second before exiting.
// Given how many times garble runs via toolexec, that is very slow!
// If GORACE is set, we assume that the caller knows what they are doing,
// and we don't try to replace or modify their flags.
if os.Getenv("GORACE") == "" {
os.Setenv("GORACE", "atexit_sleep_ms=10")
}
if os.Getenv("RUN_GARBLE_MAIN") == "true" {
main()
return
}
testscript.Main(garbleMain{m}, map[string]func(){
"garble": main,
})
}
type garbleMain struct {
m *testing.M
}
func (m garbleMain) Run() int {
// Start the Go proxy server running for all tests.
srv, err := goproxytest.NewServer("testdata/mod", "")
if err != nil {
panic(fmt.Sprintf("cannot start proxy: %v", err))
}
proxyURL = srv.URL
return m.m.Run()
}
var update = flag.Bool("u", false, "update testscript output files")
func TestScript(t *testing.T) {
t.Parallel()
execPath, err := os.Executable()
qt.Assert(t, qt.IsNil(err))
tempCacheDir := t.TempDir()
hostCacheDir, err := os.UserCacheDir()
qt.Assert(t, qt.IsNil(err))
p := testscript.Params{
Dir: filepath.Join("testdata", "script"),
Setup: func(env *testscript.Env) error {
// Use testdata/mod as our module proxy.
env.Setenv("GOPROXY", proxyURL)
// gotoolchain.txtar is one test which wants to reuse GOMODCACHE.
out, err := exec.Command("go", "env", "GOMODCACHE").Output()
if err != nil {
return err
}
env.Setenv("HOST_GOMODCACHE", strings.TrimSpace(string(out)))
// We use our own GOPROXY above, so avoid using sum.golang.org,
// as we would fail to update any go.sum file in the testscripts.
env.Setenv("GONOSUMDB", "*")
// "go build" starts many short-lived Go processes,
// such as asm, buildid, compile, and link.
// They don't allocate huge amounts of memory,
// and they'll exit within seconds,
// so using the GC is basically a waste of CPU.
// Turn it off entirely, releasing memory on exit.
//
// We don't want this setting always on,
// as it could result in memory problems for users.
// But it helps for our test suite,
// as the packages are relatively small.
env.Setenv("GOGC", "off")
env.Setenv("gofullversion", runtime.Version())
env.Setenv("EXEC_PATH", execPath)
if os.Getenv("GOCOVERDIR") != "" {
// Don't share cache dirs with the host if we want to collect code
// coverage. Otherwise, the coverage info might be incomplete.
env.Setenv("GOCACHE", filepath.Join(tempCacheDir, "go-cache"))
env.Setenv("GARBLE_CACHE", filepath.Join(tempCacheDir, "garble-cache"))
} else {
// GOCACHE is initialized by gotooltest to use the host's cache.
env.Setenv("GARBLE_CACHE", filepath.Join(hostCacheDir, "garble"))
}
return nil
},
// TODO: this condition should probably be supported by gotooltest
Condition: func(cond string) (bool, error) {
switch cond {
case "cgo":
out, err := exec.Command("go", "env", "CGO_ENABLED").Output()
if err != nil {
return false, err
}
result := strings.TrimSpace(string(out))
switch result {
case "0", "1":
return result == "1", nil
default:
return false, fmt.Errorf("unknown CGO_ENABLED: %q", result)
}
}
return false, fmt.Errorf("unknown condition")
},
Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
"sleep": sleep,
"binsubstr": binsubstr,
"bincmp": bincmp,
"generate-literals": generateLiterals,
"setenvfile": setenvfile,
"grepfiles": grepfiles,
"setup-go": setupGo,
},
UpdateScripts: *update,
RequireExplicitExec: true,
RequireUniqueNames: true,
}
if err := gotooltest.Setup(&p); err != nil {
t.Fatal(err)
}
testscript.Run(t, p)
}
func createFile(ts *testscript.TestScript, path string) *os.File {
file, err := os.Create(ts.MkAbs(path))
if err != nil {
ts.Fatalf("%v", err)
}
return file
}
// sleep is akin to a shell's sleep builtin.
// Note that tests should almost never use this; it's currently only used to
// work around a low-level Go syscall race on Linux.
func sleep(ts *testscript.TestScript, neg bool, args []string) {
if len(args) != 1 {
ts.Fatalf("usage: sleep duration")
}
d, err := time.ParseDuration(args[0])
if err != nil {
ts.Fatalf("%v", err)
}
time.Sleep(d)
}
func binsubstr(ts *testscript.TestScript, neg bool, args []string) {
if len(args) < 2 {
ts.Fatalf("usage: binsubstr file substr...")
}
data := ts.ReadFile(args[0])
var failed []string
for _, substr := range args[1:] {
match := strings.Contains(data, substr)
if match && neg {
failed = append(failed, substr)
} else if !match && !neg {
failed = append(failed, substr)
}
}
if len(failed) > 0 && neg {
ts.Fatalf("unexpected match for %q in %s", failed, args[0])
} else if len(failed) > 0 {
ts.Fatalf("expected match for %q in %s", failed, args[0])
}
}
func bincmp(ts *testscript.TestScript, neg bool, args []string) {
if len(args) != 2 {
ts.Fatalf("usage: bincmp file1 file2")
}
for _, arg := range args {
switch arg {
case "stdout", "stderr":
ts.Fatalf("bincmp is for binary files. did you mean cmp?")
}
}
data1 := ts.ReadFile(args[0])
data2 := ts.ReadFile(args[1])
if neg {
if data1 == data2 {
ts.Fatalf("%s and %s don't differ", args[0], args[1])
}
return
}
if data1 != data2 {
outDir := "bincmp_output"
err := os.MkdirAll(outDir, 0o777)
ts.Check(err)
file1, err := os.CreateTemp(outDir, "file1-*")
ts.Check(err)
_, err = file1.Write([]byte(data1))
ts.Check(err)
err = file1.Close()
ts.Check(err)
file2, err := os.CreateTemp(outDir, "file2-*")
ts.Check(err)
_, err = file2.Write([]byte(data2))
ts.Check(err)
err = file2.Close()
ts.Check(err)
ts.Logf("wrote files to %s and %s; try inspecting with diffoscope",
file1.Name(), file2.Name())
sizeDiff := len(data2) - len(data1)
ts.Fatalf("%s and %s differ; size diff: %+d",
args[0], args[1], sizeDiff)
}
}
var testRand = mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
const uniqueLitString = "garble_unique_string"
// generateLiterals creates a source file with random string literals appended
// to a global var in init, preventing the compiler from optimizing them away.
func generateLiterals(ts *testscript.TestScript, neg bool, args []string) {
if neg {
ts.Fatalf("unsupported: ! generate-literals")
}
if len(args) != 1 {
ts.Fatalf("usage: generate-literals file")
}
codePath := args[0]
// Global string variable to which which we append string literals: `var x = ""`
globalVar := &ast.GenDecl{
Tok: token.VAR,
Specs: []ast.Spec{
&ast.ValueSpec{
Names: []*ast.Ident{ast.NewIdent("x")},
Values: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `""`},
},
},
},
}
var statements []ast.Stmt
// 100 literals up to MaxSize, all containing uniqueLitString.
for range 100 {
randSize := testRand.Intn(literals.MaxSize - len(uniqueLitString) + 1)
buffer := make([]byte, randSize)
testRand.Read(buffer)
statements = append(
statements,
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("x")},
Tok: token.ADD_ASSIGN,
Rhs: []ast.Expr{ah.StringLit(string(buffer) + uniqueLitString)},
},
)
}
// 5 huge literals past MaxSize, without uniqueLitString; not obfuscated.
for range 5 {
size := literals.MaxSize + 1 + testRand.Intn(128<<10)
buffer := make([]byte, size)
testRand.Read(buffer)
statements = append(
statements,
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("x")},
Tok: token.ADD_ASSIGN,
Rhs: []ast.Expr{ah.StringLit(string(buffer))},
},
)
}
// An `init` function which includes all assignments from above
initFunc := &ast.FuncDecl{
Name: &ast.Ident{
Name: "init",
},
Type: &ast.FuncType{},
Body: ah.BlockStmt(statements...),
}
// A file with the global string variable and init function
file := &ast.File{
Name: ast.NewIdent("main"),
Decls: []ast.Decl{
globalVar,
initFunc,
},
}
codeFile := createFile(ts, codePath)
defer codeFile.Close()
if err := printer.Fprint(codeFile, token.NewFileSet(), file); err != nil {
ts.Fatalf("%v", err)
}
}
func setenvfile(ts *testscript.TestScript, neg bool, args []string) {
if neg {
ts.Fatalf("unsupported: ! setenvfile")
}
if len(args) != 2 {
ts.Fatalf("usage: setenvfile name file")
}
ts.Setenv(args[0], ts.ReadFile(args[1]))
}
func grepfiles(ts *testscript.TestScript, neg bool, args []string) {
if len(args) != 2 {
ts.Fatalf("usage: grepfiles path pattern")
}
anyFound := false
path, pattern := ts.MkAbs(args[0]), args[1]
rx := regexp.MustCompile(pattern)
if err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if rx.MatchString(path) {
if neg {
return fmt.Errorf("%q matches %q", path, pattern)
} else {
anyFound = true
return fs.SkipAll
}
}
return nil
}); err != nil {
ts.Fatalf("%s", err)
}
if !neg && !anyFound {
ts.Fatalf("no matches for %q", pattern)
}
}
func setupGo(ts *testscript.TestScript, neg bool, args []string) {
if neg || len(args) != 1 {
ts.Fatalf("usage: setup-go version")
}
// Download the version of Go specified as an argument, cache it in GOMODCACHE,
// and get its GOROOT directory inside the cache so we can use it.
cmd := exec.Command("go", "env", "GOROOT")
cmd.Env = append(cmd.Environ(), "GOTOOLCHAIN="+args[0])
out, err := cmd.Output()
ts.Check(err)
goroot := strings.TrimSpace(string(out))
ts.Setenv("PATH", filepath.Join(goroot, "bin")+string(os.PathListSeparator)+ts.Getenv("PATH"))
// Remove GOROOT from the environment, as it is unnecessary and gets in the way
// when we want to test GOTOOLCHAIN upgrades, which will need different GOROOTs.
ts.Setenv("GOROOT", "")
}
func TestSplitFlagsFromArgs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
want [2][]string
}{
{"Empty", []string{}, [2][]string{{}, nil}},
{
"JustFlags",
[]string{"-foo", "bar", "-baz"},
[2][]string{{"-foo", "bar", "-baz"}, nil},
},
{
"JustArgs",
[]string{"some", "pkgs"},
[2][]string{{}, {"some", "pkgs"}},
},
{
"FlagsAndArgs",
[]string{"-foo=bar", "baz"},
[2][]string{{"-foo=bar"}, {"baz"}},
},
{
"BoolFlagsAndArgs",
[]string{"-race", "pkg"},
[2][]string{{"-race"}, {"pkg"}},
},
{
"ExplicitBoolFlag",
[]string{"-race=true", "pkg"},
[2][]string{{"-race=true"}, {"pkg"}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
flags, args := splitFlagsFromArgs(test.args)
got := [2][]string{flags, args}
qt.Assert(t, qt.DeepEquals(got, test.want))
})
}
}
func TestFilterForwardBuildFlags(t *testing.T) {
t.Parallel()
tests := []struct {
name string
flags []string
want []string
}{
{"Empty", []string{}, nil},
{
"NoBuild",
[]string{"-short", "-json"},
nil,
},
{
"Mixed",
[]string{"-short", "-tags", "foo", "-mod=readonly", "-json"},
[]string{"-tags", "foo", "-mod=readonly"},
},
{
"NonBinarySkipped",
[]string{"-o", "binary", "-tags", "foo"},
[]string{"-tags", "foo"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got, _ := filterForwardBuildFlags(test.flags)
qt.Assert(t, qt.DeepEquals(got, test.want))
})
}
}
func TestFlagValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
flags []string
flagName string
want string
}{
{"StrSpace", []string{"-buildid", "bar"}, "-buildid", "bar"},
{"StrSpaceDash", []string{"-buildid", "-bar"}, "-buildid", "-bar"},
{"StrEqual", []string{"-buildid=bar"}, "-buildid", "bar"},
{"StrEqualDash", []string{"-buildid=-bar"}, "-buildid", "-bar"},
{"StrMissing", []string{"-foo"}, "-buildid", ""},
{"StrNotFollowed", []string{"-buildid"}, "-buildid", ""},
{"StrEmpty", []string{"-buildid="}, "-buildid", ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := flagValue(test.flags, test.flagName)
qt.Assert(t, qt.DeepEquals(got, test.want))
})
}
}