486 lines
13 KiB
Go
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)
|
|
}
|
|
}
|