blob: 8017acbeb8d2781f6b9bb50b6c91235af5ac2720 [file]
package client
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/bazelbuild/remote-apis-sdks/go/pkg/filemetadata"
"github.com/google/go-cmp/cmp"
)
func TestGetAbsPath(t *testing.T) {
tests := []struct {
desc string
base string
relPath string
wantErr bool
wantRes string
}{
{
desc: "simple file under base",
base: "/tmp/output",
relPath: "subdir/file.txt",
wantRes: "/tmp/output/subdir/file.txt",
},
{
desc: "base itself",
base: "/tmp/output",
relPath: ".",
wantRes: "/tmp/output",
},
{
desc: "absolute path rejected",
base: "/tmp/output",
relPath: "/etc/passwd",
wantErr: true,
},
{
desc: "full escape rejected",
base: "/tmp/output",
relPath: "../../etc/passwd",
wantErr: true,
},
{
// Sibling directory whose name starts with the same prefix as base.
// e.g. base="/tmp/output", sibling="/tmp/outputevil"
// Previously HasPrefix check was tricked into allowing this.
desc: "sibling directory with shared prefix rejected",
base: "/tmp/output",
relPath: "../outputevil/secret",
wantErr: true,
},
{
desc: "another sibling prefix bypass rejected",
base: "/home/user/build/output",
relPath: "../outputextra/malware.sh",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
res, err := getAbsPath(tc.base, tc.relPath)
if tc.wantErr {
if err == nil {
t.Errorf("getAbsPath(%q, %q) = %q, want error", tc.base, tc.relPath, res)
}
return
}
if err != nil {
t.Errorf("getAbsPath(%q, %q) unexpected error: %v", tc.base, tc.relPath, err)
return
}
if res != tc.wantRes {
t.Errorf("getAbsPath(%q, %q) = %q, want %q", tc.base, tc.relPath, res, tc.wantRes)
}
})
}
}
func TestGetTargetRelPath(t *testing.T) {
execRoot := "/execRoot"
defaultSym := "symDir/sym"
tests := []struct {
desc string
path string
symMeta *filemetadata.SymlinkMetadata
wantErr bool
wantRelExecRoot string
wantRelSymDir string
}{
{
desc: "basic",
path: defaultSym,
symMeta: &filemetadata.SymlinkMetadata{Target: "foo"},
wantRelExecRoot: "symDir/foo",
wantRelSymDir: "foo",
},
{
desc: "relative target path under exec root",
path: defaultSym,
// /execRoot/symDir/../dir/foo ==> /execRoot/dir/foo
symMeta: &filemetadata.SymlinkMetadata{Target: "../dir/foo"},
wantRelExecRoot: "dir/foo",
wantRelSymDir: "../dir/foo",
},
{
desc: "relative target path escaping exec root",
path: defaultSym,
// /execRoot/symDir/../../foo ==> /foo
symMeta: &filemetadata.SymlinkMetadata{Target: "../../foo"},
wantErr: true,
},
{
desc: "deeper relative target path",
path: "base/sub/sym",
// /execRoot/base/sub/../../foo ==> /execRoot/foo
symMeta: &filemetadata.SymlinkMetadata{Target: "../../foo"},
wantRelExecRoot: "foo",
wantRelSymDir: "../../foo",
},
{
desc: "absolute target path under exec root",
path: "base/sym",
symMeta: &filemetadata.SymlinkMetadata{Target: execRoot + "/base/foo"},
wantRelExecRoot: "base/foo",
wantRelSymDir: "foo",
},
{
desc: "abs target to rel target",
path: "base/sub1/sub2/sym",
symMeta: &filemetadata.SymlinkMetadata{Target: execRoot + "/dir/foo"},
// symlinkAbsDir: /execRoot/base/sub1/sub2
// targetAbs: /execRoot/dir/foo
// target rel to symlink: ../../../dir/foo
wantRelExecRoot: "dir/foo",
wantRelSymDir: "../../../dir/foo",
},
{
desc: "absolute target path escaping exec root",
path: defaultSym,
symMeta: &filemetadata.SymlinkMetadata{Target: "/another/dir/foo"},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
relExecRoot, relSymDir, err := getTargetRelPath(execRoot, tc.path, tc.symMeta.Target)
if (err != nil) != tc.wantErr {
t.Errorf("getTargetRelPath(path=%q) error: expected=%v got=%v", tc.path, tc.wantErr, err)
}
if err == nil && (relExecRoot != tc.wantRelExecRoot || relSymDir != tc.wantRelSymDir) {
t.Errorf("getTargetRelPath(path=%q) result: expected=(%v,%v) got=(%v,%v)", tc.path, tc.wantRelExecRoot, tc.wantRelSymDir, relExecRoot, relSymDir)
}
})
}
}
func TestEvalParentSymlinks(t *testing.T) {
cache := filemetadata.NewSingleFlightCache()
mkPath := func(path string) string {
if filepath.Separator == '/' {
return path
}
return filepath.Join(strings.Split(path, "/")...)
}
testCases := []struct {
name string
// List of relative file (no directories) paths with no intermediate symlinks.
// All paths start under the "root" directory. To go outside, use `..`.
// To denote a symlink, use the format: "symlink->target", e.g. "a/b->bb".
// To denote a symlink with an absolute path for its target, prefix the target with a forward slash. E.g. "a/b->/bb".
// Absolute symlinks also start under "root". To go outside, use `..`, e.g. `a/b->/../root2/bb`.
fs []string
// The path that includes intermediate symlinks.
path string
materialize bool
wantPath string
wantSymlinks []string
wantErr bool
}{
{
name: "one_relative_simple",
fs: []string{
"wd/a->aa",
"wd/aa/b.go",
},
path: mkPath("wd/a/b.go"),
materialize: false,
wantPath: mkPath("wd/aa/b.go"),
wantSymlinks: []string{mkPath("wd/a")},
},
{
name: "one_relative_basename_symlink",
fs: []string{
"wd/a->aa",
"wd/aa/b.go->c.go",
"wd/aa/c.go",
},
path: mkPath("wd/a/b.go"),
materialize: false,
wantPath: mkPath("wd/aa/b.go"),
wantSymlinks: []string{mkPath("wd/a")},
},
{
name: "one_relative",
fs: []string{
"wd/a->../wd2/aa",
"wd2/aa/b.go",
},
path: mkPath("wd/a/b.go"),
materialize: false,
wantPath: mkPath("wd2/aa/b.go"),
wantSymlinks: []string{mkPath("wd/a")},
},
{
name: "one_absolute_simple",
fs: []string{
"wd/a->/wd/aa",
"wd/aa/b.go",
},
path: mkPath("wd/a/b.go"),
materialize: false,
wantPath: mkPath("wd/aa/b.go"),
wantSymlinks: []string{mkPath("wd/a")},
},
{
name: "one_absolute",
fs: []string{
"wd/a->/wd2/aa",
"wd2/aa/b.go",
},
path: mkPath("wd/a/b.go"),
materialize: false,
wantPath: mkPath("wd2/aa/b.go"),
wantSymlinks: []string{mkPath("wd/a")},
},
{
name: "multiple_relative",
fs: []string{
"wd/a->aa",
"wd/aa/b->bb",
"wd/aa/bb/c.go",
},
path: mkPath("wd/a/b/c.go"),
materialize: false,
wantPath: mkPath("wd/aa/bb/c.go"),
wantSymlinks: []string{
mkPath("wd/a"),
mkPath("wd/aa/b"),
},
},
{
name: "multiple_absolute",
fs: []string{
"wd/a->/wd/aa",
"wd/aa/b->/wd/aa/bb",
"wd/aa/bb/c.go",
},
path: mkPath("wd/a/b/c.go"),
materialize: false,
wantPath: mkPath("wd/aa/bb/c.go"),
wantSymlinks: []string{
mkPath("wd/a"),
mkPath("wd/aa/b"),
},
},
{
name: "one_relative_materialize_simple",
fs: []string{
"wd/a->../../root2/aa",
"../root2/aa/b.go",
},
path: mkPath("wd/a/b.go"),
materialize: true,
wantPath: mkPath("wd/a/b.go"),
wantSymlinks: nil,
},
{
name: "one_absolute_materialize_simple",
fs: []string{
"wd/a->/../root2/aa",
"../root2/aa/b.go",
},
path: mkPath("wd/a/b.go"),
materialize: true,
wantPath: mkPath("wd/a/b.go"),
wantSymlinks: nil,
},
{
name: "multiple_relative_materialize_simple",
fs: []string{
"wd/a->../../root2/aa",
"../root2/aa/b->../../root3/aaa/bb",
"../root3/aaa/bb/c.go",
},
path: mkPath("wd/a/b/c.go"),
materialize: true,
wantPath: mkPath("wd/a/b/c.go"),
wantSymlinks: nil,
},
{
name: "multiple_absolute_materialize_simple",
fs: []string{
"wd/a->/../root2/aa",
"../root2/aa/b->/../root3/aaa/bb",
"../root3/aaa/bb/c.go",
},
path: mkPath("wd/a/b/c.go"),
materialize: true,
wantPath: mkPath("wd/a/b/c.go"),
wantSymlinks: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tmp := t.TempDir()
root := filepath.Join(tmp, "root")
for _, p := range tc.fs {
slParts := strings.Split(p, "->")
absPath := filepath.Join(root, mkPath(slParts[0]))
dir := filepath.Dir(absPath)
err := os.MkdirAll(dir, 0777)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(slParts) > 1 {
target := mkPath(slParts[1])
if target[0] == '/' {
target = filepath.Join(root, target[1:])
}
err = os.Symlink(target, absPath)
} else {
err = os.WriteFile(absPath, nil, 0777)
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
evaledPath, symlinks, err := evalParentSymlinks(root, tc.path, tc.materialize, cache)
if tc.wantErr && err == nil {
t.Fatalf("expected an error, but did not get one")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
evaledPath = filepath.Clean(evaledPath)
if evaledPath != tc.wantPath {
t.Errorf("path mismatch: got %q, want %q", evaledPath, tc.wantPath)
}
sort.Strings(symlinks)
sort.Strings(tc.wantSymlinks)
if diff := cmp.Diff(tc.wantSymlinks, symlinks); diff != "" {
t.Errorf("symlinks mismatch: got +, want -\n%s", diff)
}
})
}
}
type pfCache struct {
filemetadata.Cache
mu sync.Mutex
calls map[string]int
entered chan string // If non-nil, Get sends its path here first.
gate chan struct{} // If non-nil, Get blocks until it is closed.
}
func newPFCache() *pfCache {
return &pfCache{Cache: filemetadata.NewNoopCache(), calls: map[string]int{}}
}
func (c *pfCache) Get(path string) *filemetadata.Metadata {
c.mu.Lock()
c.calls[path]++
c.mu.Unlock()
if c.entered != nil {
c.entered <- path
}
if c.gate != nil {
<-c.gate
}
return c.Cache.Get(path)
}
func (c *pfCache) count(path string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.calls[path]
}
func (c *pfCache) total() int {
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for _, v := range c.calls {
n += v
}
return n
}
func writePFFiles(t *testing.T, n int) (string, []string) {
t.Helper()
execRoot := t.TempDir()
var rels []string
for i := 0; i < n; i++ {
rel := fmt.Sprintf("f%d", i)
if err := os.WriteFile(filepath.Join(execRoot, rel), []byte(rel), 0644); err != nil {
t.Fatal(err)
}
rels = append(rels, rel)
}
return execRoot, rels
}
func pfIsClosed(ch chan struct{}) bool {
select {
case <-ch:
return true
default:
return false
}
}
func TestMetaPrefetcherNil(t *testing.T) {
execRoot, rels := writePFFiles(t, 1)
c := newPFCache()
var p *metaPrefetcher
p.prefetch(rels...)
p.prefetchOne(rels[0])
abs := filepath.Join(execRoot, rels[0])
if md := p.get(c, rels[0], abs); md == nil || md.Err != nil {
t.Fatalf("get() = %+v, want valid metadata", md)
}
if got := c.count(abs); got != 1 {
t.Errorf("got %d lookups, want 1", got)
}
p.stop()
p.stop()
}
func TestMetaPrefetcherStopIdempotentAndDisablesPrefetch(t *testing.T) {
execRoot, rels := writePFFiles(t, 3)
c := newPFCache()
p := newMetaPrefetcher(c, execRoot, &TreeSymlinkOpts{}, 2)
p.stop()
p.stop() // Must not double-close the channel.
p.prefetchOne(rels[0]) // Must not send on the closed channel.
p.prefetch(rels...)
if len(p.pending) != 0 {
t.Errorf("got %d pending after stop, want 0", len(p.pending))
}
abs := filepath.Join(execRoot, rels[0])
if md := p.get(c, rels[0], abs); md == nil || md.Err != nil {
t.Fatalf("get() after stop = %+v, want valid metadata", md)
}
if got := c.total(); got != 1 {
t.Errorf("got %d lookups, want 1 (fallback only)", got)
}
}
func TestMetaPrefetcherDuplicates(t *testing.T) {
execRoot, rels := writePFFiles(t, 1)
c := newPFCache()
p := newMetaPrefetcher(c, execRoot, &TreeSymlinkOpts{}, 2)
defer p.stop()
rel, abs := rels[0], filepath.Join(execRoot, rels[0])
p.prefetchOne(rel)
p.prefetchOne(rel)
p.prefetch(rel, rel, "")
if len(p.pending) != 1 {
t.Fatalf("got %d pending, want 1", len(p.pending))
}
<-p.pending[rel].done // Ensure worker claimed and completed it.
md1 := p.get(c, rel, abs) // Uses the future.
md2 := p.get(c, rel, abs) // Future consumed: falls back, like serial's second lookup.
if got := c.count(abs); got != 2 {
t.Errorf("got %d lookups, want 2 (matches serial)", got)
}
if md1.Digest != md2.Digest {
t.Errorf("digests differ: %v vs %v", md1.Digest, md2.Digest)
}
}
func TestMetaPrefetcherHit(t *testing.T) {
execRoot, rels := writePFFiles(t, 1)
c := newPFCache()
p := newMetaPrefetcher(c, execRoot, &TreeSymlinkOpts{}, 2)
defer p.stop()
rel, abs := rels[0], filepath.Join(execRoot, rels[0])
p.prefetchOne(rel)
f := p.pending[rel]
if f == nil {
t.Fatalf("expected future for %s in pending", rel)
}
<-f.done // Ensure worker completed it before get so get can't steal.
md := p.get(c, rel, abs)
if md == nil || md.Err != nil {
t.Fatalf("get() = %+v, want valid metadata", md)
}
if got := c.count(abs); got != 1 {
t.Errorf("got %d lookups, want 1", got)
}
}
func TestMetaPrefetcherGetPathMismatch(t *testing.T) {
execRoot, rels := writePFFiles(t, 2)
c := newPFCache()
p := newMetaPrefetcher(c, execRoot, &TreeSymlinkOpts{}, 2)
defer p.stop()
absA, absB := filepath.Join(execRoot, rels[0]), filepath.Join(execRoot, rels[1])
p.prefetchOne(rels[0])
<-p.pending[rels[0]].done // Ensure worker has prefetched A to test path mismatch fallback.
md := p.get(c, rels[0], absB) // Resolved differently than prefetched: must not reuse A's digest.
if want := filemetadata.Compute(absB); md.Digest != want.Digest {
t.Errorf("got digest %v, want %v (of %s)", md.Digest, want.Digest, absB)
}
if len(p.pending) != 0 {
t.Errorf("future not consumed")
}
if c.count(absA) != 1 || c.count(absB) != 1 {
t.Errorf("got lookups A=%d B=%d, want 1 each", c.count(absA), c.count(absB))
}
}
func TestMetaPrefetcherMissingAndUnqueued(t *testing.T) {
execRoot := t.TempDir()
c := newPFCache()
p := newMetaPrefetcher(c, execRoot, &TreeSymlinkOpts{}, 2)
defer p.stop()
p.prefetchOne("nope")
if md := p.get(c, "nope", filepath.Join(execRoot, "nope")); md == nil || md.Err == nil {
t.Errorf("get(missing) = %+v, want metadata with an error", md)
}
if md := p.get(c, "never_queued", filepath.Join(execRoot, "never_queued")); md == nil {
t.Errorf("get(unqueued) = nil")
}
}
// More paths than the channel holds with a single worker: prefetch must block, not drop or deadlock.
func TestMetaPrefetcherBackpressure(t *testing.T) {
n := prefetchQueueSize + 500
execRoot := t.TempDir()
c := newPFCache()
p := newMetaPrefetcher(c, execRoot, &TreeSymlinkOpts{}, 1)
defer p.stop()
rels := make([]string, n)
for i := range rels {
rels[i] = fmt.Sprintf("missing%d", i) // Lookups of missing files are fast.
}
done := make(chan struct{})
go func() {
defer close(done)
p.prefetch(rels...)
}()
select {
case <-done:
case <-time.After(time.Minute):
t.Fatal("prefetch did not return: possible deadlock")
}
if len(p.pending) != n {
t.Fatalf("got %d pending, want %d (nothing dropped)", len(p.pending), n)
}
for _, r := range rels {
p.get(c, r, filepath.Join(execRoot, r))
}
if got := c.total(); got != n {
t.Errorf("got %d lookups, want %d (each exactly once)", got, n)
}
}
// stop with work still queued must skip the queued lookups, close every future and not hang.
func TestMetaPrefetcherStopWithQueuedWork(t *testing.T) {
const workers, n = 2, 100
execRoot, rels := writePFFiles(t, n)
c := newPFCache()
c.entered = make(chan string, n)
c.gate = make(chan struct{})
p := newMetaPrefetcher(c, execRoot, &TreeSymlinkOpts{}, workers)
p.prefetch(rels...)
var futures []*metaFuture
for _, f := range p.pending {
futures = append(futures, f)
}
for i := 0; i < workers; i++ {
<-c.entered // Every worker is now blocked inside cache.Get.
}
stopped := make(chan struct{})
go func() {
defer close(stopped)
p.stop()
}()
for !p.stopped.Load() {
time.Sleep(time.Millisecond)
}
close(c.gate)
select {
case <-stopped:
case <-time.After(time.Minute):
t.Fatal("stop did not return: possible deadlock")
}
for _, f := range futures {
if !pfIsClosed(f.done) {
t.Errorf("future for %s not closed after stop", f.relPath)
}
}
if got := c.total(); got != workers {
t.Errorf("got %d lookups, want %d (queued work skipped after stop)", got, workers)
}
}
func TestMetaPrefetcherZeroWorkers(t *testing.T) {
if p := newMetaPrefetcher(newPFCache(), t.TempDir(), &TreeSymlinkOpts{}, 0); p != nil {
t.Fatal("newMetaPrefetcher(workers=0) != nil; blocking prefetch would deadlock")
}
}
type gatedAbsPathCache struct {
filemetadata.Cache
gatedPath string
entered chan string
gate chan struct{}
}
func (c *gatedAbsPathCache) Get(path string) *filemetadata.Metadata {
if path == c.gatedPath {
if c.entered != nil {
c.entered <- path
}
<-c.gate
}
return c.Cache.Get(path)
}
func TestMetaPrefetcherClaimByGet(t *testing.T) {
execRoot, rels := writePFFiles(t, 2)
abs0, abs1 := filepath.Join(execRoot, rels[0]), filepath.Join(execRoot, rels[1])
c := newPFCache()
gate := make(chan struct{})
gateCache := &gatedAbsPathCache{
Cache: c,
gatedPath: abs0,
entered: make(chan string, 1),
gate: gate,
}
// 1 worker: will pick up rels[0] and block inside Get on gate.
p := newMetaPrefetcher(gateCache, execRoot, &TreeSymlinkOpts{}, 1)
defer p.stop()
p.prefetch(rels[0], rels[1])
// Wait until the single worker is blocked on abs0.
select {
case <-gateCache.entered:
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for worker to enter lookup of abs0")
}
// rels[1] is still queued in work channel. Calling get for rels[1] must claim it and return
// immediately without waiting for gate to be closed. Wrap in timeout so regression fails instead of hanging.
type getResult struct {
md *filemetadata.Metadata
}
done := make(chan getResult, 1)
go func() {
md := p.get(gateCache, rels[1], abs1)
done <- getResult{md: md}
}()
var res getResult
select {
case res = <-done:
case <-time.After(2 * time.Second):
t.Fatalf("get(%s) hung or waited for worker while gate was closed", rels[1])
}
if res.md == nil || res.md.Err != nil {
t.Fatalf("get(%s) = %+v, want valid metadata", rels[1], res.md)
}
// Unblock the worker so it finishes abs0 and drains rels[1].
close(gate)
abs0Meta := p.get(gateCache, rels[0], abs0)
if abs0Meta == nil || abs0Meta.Err != nil {
t.Fatalf("get(%s) = %+v, want valid metadata", rels[0], abs0Meta)
}
p.stop()
// Both paths must have been looked up exactly once: abs0 by worker, abs1 by get.
if got := c.count(abs0); got != 1 {
t.Errorf("got %d lookups for abs0, want 1", got)
}
if got := c.count(abs1); got != 1 {
t.Errorf("got %d lookups for abs1, want 1", got)
}
}