//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 /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) } }