Files
exec/proot/proot_test.go
T

486 lines
13 KiB
Go

//go:build (linux || android) && (amd64 || arm64)
package proot_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"os"
osexec "os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
v1 "github.com/google/go-containerregistry/pkg/v1"
"sirherobrine23.com.br/go-bds/exec/v2/process"
"sirherobrine23.com.br/go-bds/exec/v2/proot"
prootext "sirherobrine23.com.br/go-bds/exec/v2/proot/extensions/extensions"
prootfs "sirherobrine23.com.br/go-bds/exec/v2/proot/extensions/fs"
prootoverlayfs "sirherobrine23.com.br/go-bds/exec/v2/proot/extensions/overlayfs"
"sirherobrine23.com.br/go-bds/exec/v2/tools/local_extract_registry"
bdsoverlayfs "sirherobrine23.com.br/go-bds/overlayfs"
)
const (
prootCommandTimeout = 90 * time.Second
prootAptCommandTimeout = 6 * time.Minute
)
type prootRunOptions struct {
cwd string
env process.Env
timeout time.Duration
extensions []prootext.Extension
}
func TestProotCoreSemantics(t *testing.T) {
rootfs := prepareDebianRootfs(t)
mustWriteFile(t, filepath.Join(rootfs, "inside.txt"), "inside\n")
mustMkdir(t, filepath.Join(rootfs, "work"))
t.Run("guest paths are isolated from host paths", func(t *testing.T) {
hostSecret := filepath.Join(t.TempDir(), "host-secret")
mustWriteFile(t, hostSecret, "host-only\n")
script := fmt.Sprintf(`
set -eu
if test -e %q; then
echo "guest unexpectedly reached host path"
exit 1
fi
cat /inside.txt
printf guest-write > /tmp/proot-write
`, hostSecret)
stdout, _ := runShell(t, rootfs, script, prootRunOptions{})
if stdout != "inside\n" {
t.Fatalf("cat /inside.txt output = %q, want %q", stdout, "inside\n")
}
assertFileContent(t, filepath.Join(rootfs, "tmp/proot-write"), "guest-write")
})
t.Run("start resolves executable from guest PATH", func(t *testing.T) {
binDir := filepath.Join(rootfs, "opt/proot-test/bin")
mustMkdir(t, binDir)
hello := filepath.Join(binDir, "hello-proot")
mustWriteFile(t, hello, "#!/bin/sh\nprintf path-ok\n")
mustChmod(t, hello, 0755)
stdout, _ := runProot(t, rootfs, []string{"hello-proot"}, prootRunOptions{
env: process.Env{"PATH": "/opt/proot-test/bin:/usr/bin:/bin"},
})
if stdout != "path-ok" {
t.Fatalf("PATH-resolved command output = %q, want %q", stdout, "path-ok")
}
})
t.Run("cwd and relative writes stay inside rootfs", func(t *testing.T) {
stdout, _ := runShell(t, rootfs, `
set -eu
pwd
printf cwd-ok > rel-file
`, prootRunOptions{cwd: "/work"})
if stdout != "/work\n" {
t.Fatalf("pwd output = %q, want %q", stdout, "/work\n")
}
assertFileContent(t, filepath.Join(rootfs, "work/rel-file"), "cwd-ok")
})
t.Run("proc self fd resolves guest file descriptors", func(t *testing.T) {
stdout, _ := runShell(t, rootfs, `
set -eu
exec 3</inside.txt
cat /proc/self/fd/3
`, prootRunOptions{})
if stdout != "inside\n" {
t.Fatalf("cat /proc/self/fd/3 output = %q, want %q", stdout, "inside\n")
}
})
t.Run("absolute symlinks remain guest visible", func(t *testing.T) {
stdout, _ := runShell(t, rootfs, `
set -eu
ln -sf /inside.txt /tmp/inside-link
readlink /tmp/inside-link
cat /tmp/inside-link
`, prootRunOptions{})
if stdout != "/inside.txt\ninside\n" {
t.Fatalf("symlink output = %q, want %q", stdout, "/inside.txt\ninside\n")
}
})
t.Run("path translation preserves tracee string buffers", func(t *testing.T) {
certsDir := filepath.Join(rootfs, "usr/share/ca-certificates/mozilla")
mustMkdir(t, certsDir)
mustWriteFile(t, filepath.Join(certsDir, "Test_Root.crt"), "test certificate\n")
runShell(t, rootfs, `
set -eu
CERTSDIR=/usr/share/ca-certificates
crt=mozilla/Test_Root.crt
test -f "$CERTSDIR/$crt"
printf '%s\n' "$CERTSDIR/$crt" > /tmp/cert-path
`, prootRunOptions{})
assertFileContent(t, filepath.Join(rootfs, "tmp/cert-path"), "/usr/share/ca-certificates/mozilla/Test_Root.crt\n")
})
t.Run("uid and gid are reported as root", func(t *testing.T) {
stdout, _ := runShell(t, rootfs, `
set -eu
id -u
id -g
`, prootRunOptions{})
if stdout != "0\n0\n" {
t.Fatalf("id output = %q, want %q", stdout, "0\n0\n")
}
})
}
func TestProotOverlayFSExtensionCommands(t *testing.T) {
rootfs := filepath.Join(t.TempDir(), "rootfs")
upper := filepath.Join(t.TempDir(), "upper")
workdir := filepath.Join(t.TempDir(), "work")
buildStaticOverlayTestCommand(t, filepath.Join(rootfs, "bin/overlay-cmd"))
mustMkdir(t, filepath.Join(rootfs, "overlay"))
mustWriteFile(t, filepath.Join(rootfs, "overlay/lower.txt"), "lower-original\n")
mustWriteFile(t, filepath.Join(rootfs, "overlay/delete.txt"), "delete-original\n")
stdout, _ := runProot(t, rootfs, []string{"/bin/overlay-cmd"}, prootRunOptions{
extensions: []prootext.Extension{
prootoverlayfs.New("/overlay", upper, workdir, filepath.Join(rootfs, "overlay")),
},
})
want := "lower-original\nupper-changed\nlower.txt\nnew.txt\n"
if stdout != want {
t.Fatalf("overlay command output = %q, want %q", stdout, want)
}
assertFileContent(t, filepath.Join(rootfs, "overlay/lower.txt"), "lower-original\n")
assertFileContent(t, filepath.Join(rootfs, "overlay/delete.txt"), "delete-original\n")
assertFileContent(t, filepath.Join(upper, "lower.txt"), "upper-changed\n")
assertFileContent(t, filepath.Join(upper, "new.txt"), "upper-created\n")
if _, err := os.Stat(filepath.Join(upper, bdsoverlayfs.OpaqueWhiteout+"delete.txt")); err != nil {
t.Fatalf("overlay whiteout was not created: %v", err)
}
}
func TestProotReadOnlyFSExtensionCommands(t *testing.T) {
rootfs := filepath.Join(t.TempDir(), "rootfs")
assets := filepath.Join(t.TempDir(), "assets")
buildStaticReadOnlyFSTestCommand(t, filepath.Join(rootfs, "bin/fs-cmd"))
mustMkdir(t, assets)
mustMkdir(t, filepath.Join(assets, "dir"))
mustWriteFile(t, filepath.Join(assets, "file.txt"), "asset-original\n")
mustWriteFile(t, filepath.Join(assets, "dir/nested.txt"), "nested\n")
stdout, _ := runProot(t, rootfs, []string{"/bin/fs-cmd"}, prootRunOptions{
extensions: []prootext.Extension{
prootfs.New("/assets", os.DirFS(assets)),
},
})
want := "asset-original\nfile.txt\nwrite-blocked\nremove-blocked\n"
if stdout != want {
t.Fatalf("readonly fs command output = %q, want %q", stdout, want)
}
assertFileContent(t, filepath.Join(assets, "file.txt"), "asset-original\n")
if _, err := os.Stat(filepath.Join(assets, "new.txt")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("new.txt stat error = %v, want not exist", err)
}
}
func buildStaticReadOnlyFSTestCommand(t *testing.T, output string) {
t.Helper()
mustMkdir(t, filepath.Dir(output))
src := filepath.Join(t.TempDir(), "main.go")
err := os.WriteFile(src, []byte(`package main
import (
"errors"
"fmt"
"os"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func main() {
data, err := os.ReadFile("/assets/file.txt")
must(err)
fmt.Print(string(data))
entries, err := os.ReadDir("/assets")
must(err)
for _, entry := range entries {
if entry.Name() == "file.txt" {
fmt.Println(entry.Name())
}
}
if err := os.WriteFile("/assets/new.txt", []byte("no\n"), 0644); err == nil || !errors.Is(err, os.ErrPermission) {
fmt.Printf("write-error:%v\n", err)
os.Exit(1)
}
fmt.Println("write-blocked")
if err := os.Remove("/assets/file.txt"); err == nil || !errors.Is(err, os.ErrPermission) {
fmt.Printf("remove-error:%v\n", err)
os.Exit(1)
}
fmt.Println("remove-blocked")
}
`), 0644)
if err != nil {
t.Fatalf("write readonly fs test command source: %v", err)
}
cmd := osexec.Command("go", "build", "-trimpath", "-o", output, src)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build readonly fs test command: %v\n%s", err, out)
}
}
func buildStaticOverlayTestCommand(t *testing.T, output string) {
t.Helper()
mustMkdir(t, filepath.Dir(output))
src := filepath.Join(t.TempDir(), "main.go")
err := os.WriteFile(src, []byte(`package main
import (
"errors"
"fmt"
"os"
"sort"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func main() {
data, err := os.ReadFile("/overlay/lower.txt")
must(err)
fmt.Print(string(data))
must(os.WriteFile("/overlay/lower.txt", []byte("upper-changed\n"), 0644))
data, err = os.ReadFile("/overlay/lower.txt")
must(err)
fmt.Print(string(data))
must(os.WriteFile("/overlay/new.txt", []byte("upper-created\n"), 0644))
must(os.Remove("/overlay/delete.txt"))
if _, err := os.Stat("/overlay/delete.txt"); err == nil || !errors.Is(err, os.ErrNotExist) {
fmt.Println("delete-still-visible")
os.Exit(1)
}
entries, err := os.ReadDir("/overlay")
must(err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
sort.Strings(names)
for _, name := range names {
fmt.Println(name)
}
}
`), 0644)
if err != nil {
t.Fatalf("write overlay test command source: %v", err)
}
cmd := osexec.Command("go", "build", "-trimpath", "-o", output, src)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build overlay test command: %v\n%s", err, out)
}
}
func TestProotAptNetworkingIntegration(t *testing.T) {
rootfs := prepareDebianRootfs(t)
runProot(t, rootfs, []string{"apt-get", "update"}, prootRunOptions{timeout: prootAptCommandTimeout})
runProot(t, rootfs, []string{"apt-get", "install", "-y", "--no-install-recommends", "ca-certificates", "curl", "wget"}, prootRunOptions{timeout: prootAptCommandTimeout})
runProot(t, rootfs, []string{"curl", "-fsSL", "--max-time", "20", "-o", "/dev/null", "https://google.com"}, prootRunOptions{timeout: prootCommandTimeout})
runProot(t, rootfs, []string{"wget", "--spider", "--timeout=20", "--tries=1", "https://google.com"}, prootRunOptions{timeout: prootCommandTimeout})
}
func prepareDebianRootfs(t *testing.T) string {
t.Helper()
rootfs := filepath.Join(t.TempDir(), "rootfs")
if _, err := os.Stat(rootfs); err == nil {
return rootfs
} else if !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("stat rootfs: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), prootCommandTimeout)
defer cancel()
err := local_extract_registry.PullImage(
ctx,
"debian:sid",
rootfs,
v1.Platform{OS: "linux", Architecture: runtime.GOARCH},
)
if err != nil {
t.Fatalf("pull debian:sid rootfs: %v", err)
}
return rootfs
}
func runShell(t *testing.T, rootfs, script string, opt prootRunOptions) (string, string) {
t.Helper()
return runProot(t, rootfs, []string{"/bin/sh", "-c", script}, opt)
}
func runProot(t *testing.T, rootfs string, args []string, opt prootRunOptions) (string, string) {
t.Helper()
timeout := opt.timeout
if timeout == 0 {
timeout = prootCommandTimeout
}
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
env := process.Env{"DEBIAN_FRONTEND": "noninteractive"}
maps.Copy(env, opt.env)
var stdout bytes.Buffer
var stderr bytes.Buffer
stdoutLog := newTestingLogWriter(t, "stdout")
stderrLog := newTestingLogWriter(t, "stderr")
t.Logf("proot run: %s", shellArgs(args))
proc := proot.Proot{Rootfs: rootfs, Extensions: opt.extensions}
err := proc.Start(&process.Exec{
Arguments: append([]string(nil), args...),
Cwd: opt.cwd,
Environment: env,
Context: ctx,
Stdout: io.MultiWriter(&stdout, stdoutLog),
Stderr: io.MultiWriter(&stderr, stderrLog),
})
if err == nil {
err = proc.Wait()
}
stdoutLog.Flush()
stderrLog.Flush()
if err != nil {
t.Fatalf("proot command %s failed: %v\nstdout:\n%s\nstderr:\n%s", shellArgs(args), err, stdout.String(), stderr.String())
}
return stdout.String(), stderr.String()
}
type testingLogWriter struct {
t testing.TB
stream string
mu sync.Mutex
pending []byte
}
func newTestingLogWriter(t testing.TB, stream string) *testingLogWriter {
t.Helper()
return &testingLogWriter{t: t, stream: stream}
}
func (w *testingLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.pending = append(w.pending, p...)
for {
newline := bytes.IndexByte(w.pending, '\n')
if newline < 0 {
break
}
w.logLine(w.pending[:newline])
w.pending = w.pending[newline+1:]
}
return len(p), nil
}
func (w *testingLogWriter) Flush() {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) == 0 {
return
}
w.logLine(w.pending)
w.pending = nil
}
func (w *testingLogWriter) logLine(line []byte) {
w.t.Helper()
w.t.Logf("%s: %s", w.stream, string(line))
}
func shellArgs(args []string) string {
quoted := make([]string, 0, len(args))
for _, arg := range args {
quoted = append(quoted, fmt.Sprintf("%q", arg))
}
return strings.Join(quoted, " ")
}
func mustMkdir(t *testing.T, name string) {
t.Helper()
if err := os.MkdirAll(name, 0755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
func mustWriteFile(t *testing.T, name, content string) {
t.Helper()
if err := os.WriteFile(name, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
func mustChmod(t *testing.T, name string, mode fs.FileMode) {
t.Helper()
if err := os.Chmod(name, mode); err != nil {
t.Fatalf("chmod %s: %v", name, err)
}
}
func assertFileContent(t *testing.T, name, want string) {
t.Helper()
got, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if string(got) != want {
t.Fatalf("%s content = %q, want %q", name, string(got), want)
}
}