Files
visor/cmd/execd/serve_test.go
zeekayandHanzo Dev 0cc5982824
CI / build (push) Waiting to run
Go / generate (push) Waiting to run
fix(execd): keep the host socket from children, and git where dir says
FD 3 arrives without FD_CLOEXEC, which is how it crosses the exec into
execd, and execd never set it again, so every command, pty and git it
ran held the host's channel: it could read requests meant for the
daemon and answer in its name. newDaemon now sets FD_CLOEXEC. The test
harness made its pair with SOCK_CLOEXEC, which hid this; the daemon's
end now arrives inheritable, as under runsc. The workspace descriptor
dup'd for a top-level name closes on exec too.

The git ceiling governs only discovery, so -C, --git-dir, --work-tree
and a GIT_DIR or GIT_CONFIG_GLOBAL in env reached a repository or a
config file outside the workspace. Before its subcommand git now takes
-c and options that move nothing, and refuses the rest, since an
option execd does not know could take a value that hides the next.
GIT_* in env is refused and the global config is /dev/null, so HOME
picks no file. The ceiling test now covers each of these.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:05:18 -07:00

1016 lines
31 KiB
Go

// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build linux
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/unix"
)
// host is the other end of the socketpair: what the sandbox's owner holds.
// The daemon runs in this process on the descriptor it was handed, exactly as
// it would on FD 3 under runsc.
type host struct {
fd int
peer int // the daemon's end
root *root
done chan error
t *testing.T
pushed []*rep // frames a handle pushed while call waited for a reply
}
func serveTree(t *testing.T) *host {
t.Helper()
r, _ := tree(t)
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0)
if err != nil {
t.Fatal(err)
}
mine, theirs := fds[0], fds[1]
// runsc hands FD 3 over without FD_CLOEXEC, or the exec that starts execd
// would close it, so the daemon's end arrives that way here too.
if _, err := unix.FcntlInt(uintptr(theirs), unix.F_SETFD, 0); err != nil {
t.Fatal(err)
}
unix.SetsockoptInt(mine, unix.SOL_SOCKET, unix.SO_SNDBUF, 4*frameMax)
unix.SetsockoptInt(theirs, unix.SOL_SOCKET, unix.SO_SNDBUF, 4*frameMax)
if err := unix.SetsockoptTimeval(mine, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &unix.Timeval{Sec: 20}); err != nil {
t.Fatal(err)
}
h := &host{fd: mine, peer: theirs, root: r, done: make(chan error, 1), t: t}
d := newDaemon(theirs, r)
go func() { h.done <- d.serve() }()
t.Cleanup(func() {
unix.Close(mine)
if err := <-h.done; err != nil {
t.Errorf("serve: %v", err)
}
unix.Close(theirs)
})
return h
}
func (h *host) send(r *req) {
h.t.Helper()
frame := r.encode()
for {
_, err := unix.Write(h.fd, frame)
if err == unix.EINTR {
continue // the runtime's own signals reach a blocking syscall
}
if err != nil {
h.t.Fatalf("send: %v", err)
}
return
}
}
func (h *host) recv() *rep {
h.t.Helper()
buf := make([]byte, frameMax)
var n int
for {
got, _, err := unix.Recvfrom(h.fd, buf, 0)
if err == unix.EINTR {
continue
}
if err != nil {
h.t.Fatalf("recv: %v", err)
}
n = got
break
}
r, err := decodeRep(buf[:n])
if err != nil {
h.t.Fatalf("decode: %v", err)
}
return r
}
// call sends one action and waits for the reply that names it. A frame pushed
// by a handle in the meantime is set aside, not dropped: replies and pushes
// interleave, and a test that asked for pty output still wants it.
func (h *host) call(r *req) *rep {
h.t.Helper()
h.send(r)
for {
got := h.recv()
if got.kind == kindReply && got.id == r.id {
return got
}
h.pushed = append(h.pushed, got)
}
}
// next returns the next pushed frame, from what call set aside first.
func (h *host) next() *rep {
h.t.Helper()
if len(h.pushed) > 0 {
got := h.pushed[0]
h.pushed = h.pushed[1:]
return got
}
return h.recv()
}
func (h *host) in(parts ...string) string {
return filepath.Join(append([]string{h.root.name}, parts...)...)
}
func TestExecRunsAndReportsExit(t *testing.T) {
h := serveTree(t)
got := h.call(&req{op: opExec, id: 1, argv: []string{"/bin/sh", "-c", "echo out; echo err 1>&2; exit 3"}})
if got.exit != 3 {
t.Fatalf("exit %d err %q", got.exit, got.err)
}
if strings.TrimSpace(string(got.data)) != "out" || strings.TrimSpace(string(got.log)) != "err" {
t.Fatalf("data %q log %q", got.data, got.log)
}
}
func TestExecRunsACommandInADirectoryWithStdin(t *testing.T) {
h := serveTree(t)
got := h.call(&req{op: opExec, id: 1, command: "cat; pwd", dir: "src", data: []byte("fed in\n")})
if got.exit != 0 || got.err != "" {
t.Fatalf("exit %d err %q", got.exit, got.err)
}
want := "fed in\n" + h.in("src") + "\n"
if string(got.data) != want {
t.Fatalf("got %q want %q", got.data, want)
}
}
func TestExecRefusesADirectoryOutsideTheWorkspace(t *testing.T) {
h := serveTree(t)
got := h.call(&req{op: opExec, id: 1, argv: []string{"/bin/true"}, dir: "../outside"})
if got.err == "" {
t.Fatal("a directory outside the workspace was accepted")
}
}
func TestExecStopsAtItsTimeout(t *testing.T) {
h := serveTree(t)
got := h.call(&req{op: opExec, id: 1, argv: []string{"/bin/sh", "-c", "sleep 30"}, timeout: 200})
if !strings.Contains(got.err, "no answer within") {
t.Fatalf("err %q exit %d", got.err, got.exit)
}
}
func TestExecRefusesArgvAndCommandTogether(t *testing.T) {
h := serveTree(t)
got := h.call(&req{op: opExec, id: 1, argv: []string{"/bin/true"}, command: "true"})
if got.err == "" {
t.Fatal("argv and command were both accepted")
}
}
// A repeated id for a completed action answers the record. The effect has to
// happen once, which a side effect on disk proves.
func TestARepeatedIDDoesNotRunTheEffectTwice(t *testing.T) {
h := serveTree(t)
action := &req{op: opExec, id: 7821, command: "echo tick >> ticks"}
first := h.call(action)
if first.exit != 0 || first.err != "" {
t.Fatalf("exit %d err %q", first.exit, first.err)
}
second := h.call(action)
if second.exit != 0 || second.err != "" {
t.Fatalf("replay: exit %d err %q", second.exit, second.err)
}
body, err := os.ReadFile(h.in("ticks"))
if err != nil {
t.Fatal(err)
}
if string(body) != "tick\n" {
t.Fatalf("the command ran more than once: %q", body)
}
}
func TestAZeroIDIsRefused(t *testing.T) {
h := serveTree(t)
h.send(&req{op: opExec, id: 0, argv: []string{"/bin/true"}})
if got := h.recv(); got.err == "" {
t.Fatal("an action with no id was carried out")
}
}
func TestWriteThenReadAndStat(t *testing.T) {
h := serveTree(t)
body := []byte("package main\n\nfunc main() {}\n")
if got := h.call(&req{op: opWrite, id: 1, path: "src/app.go", data: body}); got.err != "" {
t.Fatalf("write: %s", got.err)
}
if got := h.call(&req{op: opRead, id: 2, path: "src/app.go"}); string(got.data) != string(body) {
t.Fatalf("read %q err %q", got.data, got.err)
}
// A range, which is how a file over the read bound is fetched.
got := h.call(&req{op: opRead, id: 3, path: "src/app.go", off: 8, length: 4})
if string(got.data) != "main" {
t.Fatalf("range read %q", got.data)
}
if got.size != uint64(len(body)) {
t.Fatalf("size %d want %d", got.size, len(body))
}
st := h.call(&req{op: opStat, id: 4, path: "src"})
if st.err != "" || st.flags&flagDir == 0 {
t.Fatalf("stat src: %+v", st)
}
}
func TestWriteIsAtomic(t *testing.T) {
h := serveTree(t)
write(t, h.in("src", "main.go"), "old\n")
link := h.in("src", "main.go")
before, err := os.Stat(link)
if err != nil {
t.Fatal(err)
}
if got := h.call(&req{op: opWrite, id: 1, path: "src/main.go", data: []byte("new\n")}); got.err != "" {
t.Fatalf("write: %s", got.err)
}
after, err := os.Stat(link)
if err != nil {
t.Fatal(err)
}
if os.SameFile(before, after) {
t.Fatal("the file was written in place, not renamed over")
}
body, _ := os.ReadFile(link)
if string(body) != "new\n" {
t.Fatalf("got %q", body)
}
if left, _ := filepath.Glob(h.in("src", ".*execd*")); len(left) != 0 {
t.Fatalf("a temporary file was left behind: %v", left)
}
}
func TestFileOpsRefuseAnEscape(t *testing.T) {
h := serveTree(t)
for i, r := range []*req{
{op: opRead, path: "../outside/secret"},
{op: opRead, path: "/etc/passwd"},
{op: opRead, path: "away/secret"},
{op: opWrite, path: "../outside/planted", data: []byte("x")},
{op: opStat, path: "../outside/secret"},
{op: opList, path: "../outside"},
} {
r.id = uint64(i + 1)
if got := h.call(r); got.err == "" {
t.Errorf("%s %s was allowed outside the workspace", opName(r.op), r.path)
}
}
if _, err := os.Stat(filepath.Join(filepath.Dir(h.root.name), "outside", "planted")); err == nil {
t.Fatal("a write landed outside the workspace")
}
}
func TestListPagesADirectory(t *testing.T) {
h := serveTree(t)
for _, name := range []string{"a", "b", "c"} {
write(t, h.in("src", name), name)
}
all := h.call(&req{op: opList, id: 1, path: "src"})
if all.err != "" {
t.Fatalf("list: %s", all.err)
}
if all.size != 4 || len(all.entries) != 4 { // a, b, c, main.go
t.Fatalf("got %d entries of %d: %+v", len(all.entries), all.size, all.entries)
}
page := h.call(&req{op: opList, id: 2, path: "src", off: 2, length: 1})
if len(page.entries) != 1 || page.entries[0].name != "c" {
t.Fatalf("page %+v", page.entries)
}
}
func TestPatchAppliesAtomically(t *testing.T) {
h := serveTree(t)
write(t, h.in("src", "main.go"), "one\ntwo\nthree\n")
diff := `--- a/src/main.go
+++ b/src/main.go
@@ -1,3 +1,3 @@
one
-two
+TWO
three
--- /dev/null
+++ b/src/extra.go
@@ -0,0 +1 @@
+made here
`
got := h.call(&req{op: opPatch, id: 1, data: []byte(diff)})
if got.err != "" {
t.Fatalf("patch: %s", got.err)
}
if got.size != 2 || len(got.entries) != 2 {
t.Fatalf("got %+v", got)
}
if body, _ := os.ReadFile(h.in("src", "main.go")); string(body) != "one\nTWO\nthree\n" {
t.Fatalf("main.go is %q", body)
}
if body, _ := os.ReadFile(h.in("src", "extra.go")); string(body) != "made here\n" {
t.Fatalf("extra.go is %q", body)
}
}
func TestPatchChangesNothingWhenOneFileDoesNotFit(t *testing.T) {
h := serveTree(t)
write(t, h.in("src", "main.go"), "one\ntwo\nthree\n")
diff := `--- a/src/main.go
+++ b/src/main.go
@@ -1,3 +1,3 @@
one
-two
+TWO
three
--- a/src/absent.go
+++ b/src/absent.go
@@ -1,1 +1,1 @@
-was here
+is here
`
got := h.call(&req{op: opPatch, id: 1, data: []byte(diff)})
if got.err == "" {
t.Fatal("a diff that does not fit was applied")
}
if body, _ := os.ReadFile(h.in("src", "main.go")); string(body) != "one\ntwo\nthree\n" {
t.Fatalf("the first file changed anyway: %q", body)
}
if left, _ := filepath.Glob(h.in("src", ".*execd*")); len(left) != 0 {
t.Fatalf("a temporary file was left behind: %v", left)
}
}
func TestPatchRefusesAPathOutsideTheWorkspace(t *testing.T) {
h := serveTree(t)
diff := "--- a/../outside/secret\n+++ b/../outside/secret\n@@ -1,1 +1,1 @@\n-not yours\n+mine\n"
if got := h.call(&req{op: opPatch, id: 1, data: []byte(diff)}); got.err == "" {
t.Fatal("a diff reached outside the workspace")
}
if body, _ := os.ReadFile(filepath.Join(filepath.Dir(h.root.name), "outside", "secret")); string(body) != "not yours\n" {
t.Fatalf("the file outside changed: %q", body)
}
}
func TestGitRunsInTheWorkspace(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
h := serveTree(t)
if got := h.call(&req{op: opGit, id: 1, argv: []string{"init", "-q"}}); got.exit != 0 {
t.Fatalf("init: exit %d err %q %s", got.exit, got.err, got.log)
}
got := h.call(&req{op: opGit, id: 2, argv: []string{"rev-parse", "--show-toplevel"}})
if got.exit != 0 {
t.Fatalf("rev-parse: exit %d %s", got.exit, got.log)
}
if strings.TrimSpace(string(got.data)) != h.root.name {
t.Fatalf("git ran in %q, not %q", strings.TrimSpace(string(got.data)), h.root.name)
}
if got := h.call(&req{op: opGit, id: 3, command: "status"}); got.err == "" {
t.Fatal("git took a command string")
}
}
func TestSpawnDrivesAPty(t *testing.T) {
h := serveTree(t)
start := h.call(&req{op: opSpawn, id: 1, argv: []string{"/bin/sh", "-i"}, cols: 100, rows: 30})
if start.err != "" || start.handle == 0 {
t.Fatalf("spawn: %+v", start)
}
n := start.handle
if got := h.call(&req{op: opResize, id: 2, handle: n, cols: 120, rows: 40}); got.err != "" {
t.Fatalf("resize: %s", got.err)
}
if got := h.call(&req{op: opPtyWrite, id: 3, handle: n, data: []byte("echo it-runs\nexit\n")}); got.err != "" {
t.Fatalf("pty.write: %s", got.err)
}
var out strings.Builder
for {
got := h.next()
if got.kind == kindData {
out.Write(got.data)
continue
}
if got.kind == kindExit {
if got.handle != n || got.id != 1 {
t.Fatalf("exit names handle %d action %d", got.handle, got.id)
}
break
}
}
if !strings.Contains(out.String(), "it-runs") {
t.Fatalf("the pty said %q", out.String())
}
}
func TestKillEndsAPty(t *testing.T) {
h := serveTree(t)
start := h.call(&req{op: opSpawn, id: 1, argv: []string{"/bin/sh", "-c", "sleep 60"}})
if start.err != "" {
t.Fatalf("spawn: %s", start.err)
}
if got := h.call(&req{op: opKill, id: 2, handle: start.handle, signal: int32(unix.SIGKILL)}); got.err != "" {
t.Fatalf("kill: %s", got.err)
}
for {
if got := h.next(); got.kind == kindExit {
if !strings.Contains(got.err, "killed by") {
t.Fatalf("exit says %q", got.err)
}
break
}
}
if got := h.call(&req{op: opPtyWrite, id: 3, handle: start.handle, data: []byte("x")}); got.err == "" {
t.Fatal("a closed handle still took a write")
}
}
func TestWatchReportsAChange(t *testing.T) {
h := serveTree(t)
start := h.call(&req{op: opWatch, id: 1, path: "src"})
if start.err != "" || start.handle == 0 {
t.Fatalf("watch: %+v", start)
}
h.send(&req{op: opWrite, id: 2, path: "src/fresh.go", data: []byte("hi\n")})
var seen bool
for !seen {
got := h.next()
if got.kind == kindEvent && got.id == 1 && got.path == "fresh.go" && got.mask != 0 {
seen = true
}
}
if got := h.call(&req{op: opKill, id: 3, handle: start.handle}); got.err != "" {
t.Fatalf("kill a watch: %s", got.err)
}
if got := h.call(&req{op: opKill, id: 4, handle: start.handle}); got.err == "" {
t.Fatal("a closed watch handle was found again")
}
}
func TestAnUnknownOpIsRefused(t *testing.T) {
h := serveTree(t)
if got := h.call(&req{op: 200, id: 1}); !strings.Contains(got.err, "unknown op") {
t.Fatalf("got %q", got.err)
}
}
func TestAReadOverTheBoundIsRefusedWithItsSize(t *testing.T) {
h := serveTree(t)
big := make([]byte, readMax+1)
write(t, h.in("big"), string(big))
got := h.call(&req{op: opRead, id: 1, path: "big"})
if !strings.Contains(got.err, "over the") {
t.Fatalf("got %q", got.err)
}
if got := h.call(&req{op: opRead, id: 2, path: "big", length: 8}); len(got.data) != 8 {
t.Fatalf("a range of a big file read %d bytes: %q", len(got.data), got.err)
}
}
// A repository above the workspace is not the workspace's repository, and
// nothing in a request moves git to it. Not the directory git starts in: the
// ceiling has to name the workspace's parent to stop the search there from the
// workspace root, which is where a request that names no directory runs. Not
// an option before the subcommand, even one behind an option execd does not
// know. Not the environment.
func TestGitCannotReachARepositoryAboveTheWorkspace(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
h := serveTree(t)
above := filepath.Dir(h.root.name)
outer := func(argv ...string) string {
t.Helper()
cmd := exec.Command("git", argv...)
cmd.Dir = above
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v above the workspace: %v %s", argv, err, out)
}
return strings.TrimSpace(string(out))
}
sign := []string{"-c", "user.name=t", "-c", "[email protected]", "-c", "commit.gpgsign=false"}
outer("init", "-q", "-b", "main")
outer(append(append([]string{}, sign...), "commit", "-q", "--allow-empty", "-m", "outer")...)
before := outer("rev-list", "--count", "HEAD")
gitDir := filepath.Join(above, ".git")
commit := append(append([]string{}, sign...), "commit", "--allow-empty", "-m", "written-from-inside")
plant := []string{"config", "--local", "execd.planted", "yes"}
for i, r := range []*req{
{argv: []string{"rev-parse", "--show-toplevel"}},
{argv: []string{"rev-parse", "--show-toplevel"}, dir: "src"},
{argv: []string{"log", "--oneline"}},
{argv: commit},
{argv: plant},
{argv: []string{"-C", "..", "rev-parse", "--show-toplevel"}},
{argv: append([]string{"-C", ".."}, plant...)},
{argv: append([]string{"-C", above}, commit...)},
{argv: append([]string{"-c", "core.quotePath=false", "-C", ".."}, plant...)},
{argv: append([]string{"--namespace", "n", "-C", ".."}, commit...)},
{argv: []string{"--git-dir=" + gitDir, "log", "--oneline"}},
{argv: append([]string{"--git-dir", gitDir, "--work-tree", above}, commit...)},
{argv: []string{"log", "--oneline"}, env: []string{"GIT_DIR=" + gitDir}},
{argv: commit, env: []string{"GIT_DIR=" + gitDir, "GIT_WORK_TREE=" + above}},
} {
r.op, r.id = opGit, uint64(i+1)
if got := h.call(r); got.exit == 0 {
t.Errorf("git %q in %q with env %q reached the repository above the workspace: %q", r.argv, r.dir, r.env, got.data)
}
}
if after := outer("rev-list", "--count", "HEAD"); after != before {
t.Fatalf("the repository above the workspace went from %s commits to %s", before, after)
}
if out := outer("config", "--local", "--list"); strings.Contains(out, "execd.planted") {
t.Fatalf("its config was written: %q", out)
}
}
// A request configures git with -c. Its environment names no file for git to
// read: a GIT_* variable is refused, and a HOME finds no global file.
func TestGitReadsNoConfigurationTheEnvironmentNames(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
h := serveTree(t)
outside := filepath.Join(filepath.Dir(h.root.name), "outside")
planted := filepath.Join(outside, ".gitconfig")
write(t, planted, "[user]\n\tname = planted\n")
for i, env := range [][]string{
{"GIT_CONFIG_GLOBAL=" + planted},
{"GIT_CONFIG_SYSTEM=" + planted},
{"HOME=" + outside},
} {
got := h.call(&req{op: opGit, id: uint64(i + 1), argv: []string{"config", "--get", "user.name"}, env: env})
if strings.Contains(string(got.data), "planted") {
t.Errorf("env %q chose git's configuration: %q", env, got.data)
}
}
got := h.call(&req{op: opGit, id: 10, argv: []string{"-c", "user.name=stated", "config", "--get", "user.name"}})
if strings.TrimSpace(string(got.data)) != "stated" {
t.Fatalf("-c user.name gave %q, exit %d err %q", got.data, got.exit, got.err)
}
}
// Before the subcommand git takes options that change how it reads and prints.
// After it every word is the subcommand's: -C there is git log's copy
// detection, not a directory.
func TestGitTakesOptionsThatMoveNothing(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
h := serveTree(t)
sign := []string{"-c", "user.name=t", "-c", "[email protected]", "-c", "commit.gpgsign=false"}
for i, argv := range [][]string{
{"init", "-q"},
append(append([]string{}, sign...), "commit", "-q", "--allow-empty", "-m", "one"),
{"--no-pager", "--no-optional-locks", "log", "-C", "--oneline"},
{"-P", "--literal-pathspecs", "status", "--short"},
} {
if got := h.call(&req{op: opGit, id: uint64(i + 1), argv: argv}); got.exit != 0 || got.err != "" {
t.Fatalf("git %q: exit %d err %q %s", argv, got.exit, got.err, got.log)
}
}
}
// A zero-length packet is a packet a sequenced-packet socket delivers. It asks
// for nothing, and it is not the host hanging up.
func TestAnEmptyPacketIsNotEndOfFile(t *testing.T) {
h := serveTree(t)
if got := h.call(&req{op: opWrite, id: 1, path: "before", data: []byte("x\n")}); got.err != "" {
t.Fatalf("write: %s", got.err)
}
for {
_, err := unix.Write(h.fd, nil)
if err == unix.EINTR {
continue
}
if err != nil {
t.Fatalf("send an empty packet: %v", err)
}
break
}
if got := h.call(&req{op: opWrite, id: 2, path: "after", data: []byte("y\n")}); got.err != "" {
t.Fatalf("after an empty packet: %s", got.err)
}
}
// The default page is one a frame can hold, and off walks a directory no
// single frame could ever carry.
func TestListPagesADirectoryTooBigForAFrame(t *testing.T) {
h := serveTree(t)
const total = 4096
if err := os.Mkdir(h.in("many"), 0o755); err != nil {
t.Fatal(err)
}
for i := 0; i < total; i++ {
write(t, h.in("many", fmt.Sprintf("file-%06d.txt", i)), "")
}
seen := 0
for id := uint64(1); seen < total; id++ {
got := h.call(&req{op: opList, id: id, path: "many", off: int64(seen)})
if got.err != "" {
t.Fatalf("list from %d: %s", seen, got.err)
}
if got.size != total {
t.Fatalf("size %d, want %d", got.size, total)
}
if len(got.entries) == 0 {
t.Fatalf("an empty page at %d of %d", seen, total)
}
for i, e := range got.entries {
if want := fmt.Sprintf("file-%06d.txt", seen+i); e.name != want {
t.Fatalf("entry %d is %q, want %q", seen+i, e.name, want)
}
}
seen += len(got.entries)
}
}
// One path string means one place. away is a symlink out of the workspace, so
// every op refuses a path through it — a write is not more permissive than a
// read because it looked at the string instead of asking the kernel.
func TestEveryOpAgreesOnAnEscapingComponent(t *testing.T) {
h := serveTree(t)
const p = "away/../redirected"
if got := h.call(&req{op: opRead, id: 1, path: p}); got.err == "" {
t.Error("a read through a symlink out of the workspace was allowed")
}
if got := h.call(&req{op: opWrite, id: 2, path: p, data: []byte("x\n")}); got.err == "" {
t.Error("a write through a symlink out of the workspace was allowed")
}
diff := "--- /dev/null\n+++ b/away/../redirected\n@@ -0,0 +1 @@\n+x\n"
if got := h.call(&req{op: opPatch, id: 3, data: []byte(diff)}); got.err == "" {
t.Error("a patch through a symlink out of the workspace was allowed")
}
for _, name := range []string{h.in("redirected"), filepath.Join(filepath.Dir(h.root.name), "redirected"), filepath.Join(filepath.Dir(h.root.name), "outside", "redirected")} {
if _, err := os.Stat(name); err == nil {
t.Errorf("something landed at %s", name)
}
}
}
// A ".." that stays beneath the workspace is a path like any other.
func TestADotDotThatStaysInsideIsTakenAsWritten(t *testing.T) {
h := serveTree(t)
got := h.call(&req{op: opRead, id: 1, path: "src/../src/main.go"})
if got.err != "" || string(got.data) != "package main\n" {
t.Fatalf("read: %q err %q", got.data, got.err)
}
if got := h.call(&req{op: opWrite, id: 2, path: "src/../src/fresh.go", data: []byte("hi\n")}); got.err != "" {
t.Fatalf("write: %s", got.err)
}
if body, _ := os.ReadFile(h.in("src", "fresh.go")); string(body) != "hi\n" {
t.Fatalf("got %q", body)
}
}
// A refused request did not happen, so its id is free and asking again runs.
func TestARefusedRequestLeavesItsIDFree(t *testing.T) {
h := serveTree(t)
if got := h.call(&req{op: opWrite, id: 99, path: "../outside/planted", data: []byte("x")}); got.err == "" {
t.Fatal("a write outside the workspace was accepted")
}
if got := h.call(&req{op: opWrite, id: 99, path: "kept", data: []byte("x\n")}); got.err != "" {
t.Fatalf("a refusal froze the id: %s", got.err)
}
if body, _ := os.ReadFile(h.in("kept")); string(body) != "x\n" {
t.Fatalf("got %q", body)
}
}
// An action that ran is recorded whatever came of it. A command killed by its
// deadline ran, so a repeat of the id answers the record: a second deadline is
// a second action and takes an id of its own.
func TestATimeoutIsRecordedBecauseItRan(t *testing.T) {
h := serveTree(t)
action := &req{op: opExec, id: 8001, command: "echo ran >> tries; sleep 30", timeout: 200}
first := h.call(action)
if !strings.Contains(first.err, "no answer within") {
t.Fatalf("err %q exit %d", first.err, first.exit)
}
again := &req{op: opExec, id: 8001, command: "echo ran >> tries; sleep 30"}
if second := h.call(again); second.err != first.err {
t.Fatalf("the record says %q, want %q", second.err, first.err)
}
body, err := os.ReadFile(h.in("tries"))
if err != nil {
t.Fatal(err)
}
if string(body) != "ran\n" {
t.Fatalf("the command ran again: %q", body)
}
}
// A staged file's name is not one a request can name, so a file of the
// workspace's own is never in the way of a write.
func TestWriteKeepsAFileThatLooksStaged(t *testing.T) {
h := serveTree(t)
write(t, h.in("f"), "one\n")
write(t, h.in(".f.execd5300"), "mine\n")
write(t, h.in(".execd5300"), "mine too\n")
if got := h.call(&req{op: opWrite, id: 5300, path: "f", data: []byte("two\n")}); got.err != "" {
t.Fatalf("write: %s", got.err)
}
for _, name := range []string{".f.execd5300", ".execd5300"} {
if body, err := os.ReadFile(h.in(name)); err != nil || !strings.HasPrefix(string(body), "mine") {
t.Errorf("%s: %q %v", name, body, err)
}
}
if left, _ := filepath.Glob(h.in(".execd*")); len(left) != 1 {
t.Fatalf("staged files left behind: %v", left)
}
}
func TestWriteReportsTheModeOnDisk(t *testing.T) {
h := serveTree(t)
was := unix.Umask(0o022)
t.Cleanup(func() { unix.Umask(was) })
got := h.call(&req{op: opWrite, id: 1, path: "runme", data: []byte("#!/bin/sh\n"), mode: 0o777})
if got.err != "" {
t.Fatalf("write: %s", got.err)
}
st, err := os.Stat(h.in("runme"))
if err != nil {
t.Fatal(err)
}
if got.mode != uint32(st.Mode().Perm()) {
t.Fatalf("the reply says %#o and the file is %#o", got.mode, st.Mode().Perm())
}
if st.Mode().Perm() != 0o777 {
t.Fatalf("the mode asked for was not the mode set: %#o", st.Mode().Perm())
}
}
func TestWriteKeepsTheModeAFileHad(t *testing.T) {
h := serveTree(t)
write(t, h.in("script"), "old\n")
if err := os.Chmod(h.in("script"), 0o755); err != nil {
t.Fatal(err)
}
got := h.call(&req{op: opWrite, id: 1, path: "script", data: []byte("new\n")})
if got.err != "" || got.mode != 0o755 {
t.Fatalf("write says mode %#o err %q", got.mode, got.err)
}
st, err := os.Stat(h.in("script"))
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o755 {
t.Fatalf("the file is %#o", st.Mode().Perm())
}
}
// One field, one meaning: mode is permission bits, wherever it is answered.
func TestStatListAndReadAgreeOnMode(t *testing.T) {
h := serveTree(t)
write(t, h.in("src", "plain.txt"), "x\n")
if err := os.Chmod(h.in("src", "plain.txt"), 0o640); err != nil {
t.Fatal(err)
}
st := h.call(&req{op: opStat, id: 1, path: "src/plain.txt"})
rd := h.call(&req{op: opRead, id: 2, path: "src/plain.txt"})
ls := h.call(&req{op: opList, id: 3, path: "src"})
var listed uint32
for _, e := range ls.entries {
if e.name == "plain.txt" {
listed = e.mode
}
}
if st.mode != 0o640 || rd.mode != 0o640 || listed != 0o640 {
t.Fatalf("stat %#o read %#o list %#o", st.mode, rd.mode, listed)
}
}
func TestReadPastTheEndIsRefused(t *testing.T) {
h := serveTree(t)
write(t, h.in("small"), "x\n")
if got := h.call(&req{op: opRead, id: 1, path: "small", off: 1 << 40}); got.err == "" {
t.Fatalf("a read a terabyte past the end answered %d bytes of a %d byte file", len(got.data), got.size)
}
if got := h.call(&req{op: opRead, id: 2, path: "small", off: 2}); got.err != "" || len(got.data) != 0 {
t.Fatalf("a read at the end: %q err %q", got.data, got.err)
}
}
func TestAnOversizeRequestNamesTheOpAndTheAction(t *testing.T) {
h := serveTree(t)
frame := (&req{op: opWrite, id: 5600, path: "toobig", data: make([]byte, frameMax)}).encode()
if len(frame) <= frameMax {
t.Fatalf("the frame is %d bytes, which fits", len(frame))
}
for {
_, err := unix.Write(h.fd, frame)
if err == unix.EINTR {
continue
}
if err != nil {
t.Fatalf("send: %v", err)
}
break
}
got := h.recv()
if got.id != 5600 || got.op != opWrite {
t.Fatalf("the refusal names op %d action %d", got.op, got.id)
}
if !strings.Contains(got.err, "over the") {
t.Fatalf("err %q", got.err)
}
if _, err := os.Stat(h.in("toobig")); err == nil {
t.Fatal("the oversize write landed")
}
}
// execd holds no credential, and a child starts from a declared environment,
// so what the host handed the daemon does not reach a command.
func TestAChildGetsADeclaredEnvironment(t *testing.T) {
h := serveTree(t)
t.Setenv("EXECD_TEST_SECRET", "sk-do-not-leak")
got := h.call(&req{op: opExec, id: 1, command: "echo [$EXECD_TEST_SECRET]; echo $PATH"})
if got.err != "" {
t.Fatalf("exec: %s", got.err)
}
if strings.Contains(string(got.data), "sk-do-not-leak") {
t.Fatalf("the daemon's environment reached the child: %q", got.data)
}
if !strings.Contains(string(got.data), "/usr/bin") {
t.Fatalf("the child got no PATH: %q", got.data)
}
got = h.call(&req{op: opExec, id: 2, argv: []string{"/bin/sh", "-c", "echo $ONLY; echo [$EXECD_TEST_SECRET]"},
env: []string{"ONLY=this"}})
if want := "this\n[]\n"; string(got.data) != want {
t.Fatalf("got %q want %q", got.data, want)
}
}
// The host's socket crosses one exec, the one that starts the daemon, and no
// other. A child that held it could read requests meant for the daemon and
// answer in its name.
func TestAChildDoesNotHoldTheHostSocket(t *testing.T) {
h := serveTree(t)
var st unix.Stat_t
if err := unix.Fstat(h.peer, &st); err != nil {
t.Fatal(err)
}
sock := fmt.Sprintf("socket:[%d]", st.Ino)
const list = "ls -l /proc/self/fd"
got := h.call(&req{op: opExec, id: 1, command: list})
if got.exit != 0 || got.err != "" {
t.Fatalf("exec: exit %d err %q %s", got.exit, got.err, got.log)
}
if strings.Contains(string(got.data), sock) {
t.Errorf("a command holds the host socket, %s:\n%s", sock, got.data)
}
start := h.call(&req{op: opSpawn, id: 2, command: list})
if start.err != "" {
t.Fatalf("spawn: %s", start.err)
}
var out strings.Builder
for got := h.next(); got.kind != kindExit; got = h.next() {
out.Write(got.data)
}
if strings.Contains(out.String(), sock) {
t.Errorf("a pty's process holds the host socket, %s:\n%s", sock, out.String())
}
}
func TestPatchNamingOneFileTwiceChangesNothing(t *testing.T) {
h := serveTree(t)
write(t, h.in("f"), "one\n")
diff := "--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-one\n+first\n" +
"--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-one\n+second\n"
if got := h.call(&req{op: opPatch, id: 1, data: []byte(diff)}); got.err == "" {
t.Fatal("a diff naming one file twice was applied")
}
if body, _ := os.ReadFile(h.in("f")); string(body) != "one\n" {
t.Fatalf("f is %q", body)
}
if left, _ := filepath.Glob(h.in(".execd*")); len(left) != 0 {
t.Fatalf("staged files left behind: %v", left)
}
}
// Two names for one file are one file, whatever the strings look like.
func TestPatchRefusesTwoNamesForOneFile(t *testing.T) {
h := serveTree(t)
write(t, h.in("f"), "one\n")
if err := os.Symlink("f", h.in("g")); err != nil {
t.Fatal(err)
}
diff := "--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-one\n+first\n" +
"--- a/g\n+++ b/g\n@@ -1,1 +1,1 @@\n-one\n+second\n"
got := h.call(&req{op: opPatch, id: 1, data: []byte(diff)})
if got.err == "" || !strings.Contains(got.err, "one file") {
t.Fatalf("err %q", got.err)
}
if body, _ := os.ReadFile(h.in("f")); string(body) != "one\n" {
t.Fatalf("f is %q", body)
}
}
func TestPatchThatWouldDeleteADirectoryChangesNothing(t *testing.T) {
h := serveTree(t)
write(t, h.in("a.txt"), "one\n")
diff := "--- a/a.txt\n+++ b/a.txt\n@@ -1,1 +1,1 @@\n-one\n+ONE\n" +
"--- a/src\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-nothing\n"
if got := h.call(&req{op: opPatch, id: 1, data: []byte(diff)}); got.err == "" {
t.Fatal("a diff deleted a directory")
}
if body, _ := os.ReadFile(h.in("a.txt")); string(body) != "one\n" {
t.Fatalf("the first file changed anyway: %q", body)
}
if _, err := os.Stat(h.in("src")); err != nil {
t.Fatalf("src: %v", err)
}
if left, _ := filepath.Glob(h.in(".execd*")); len(left) != 0 {
t.Fatalf("staged files left behind: %v", left)
}
}
func TestPatchDeletesAFile(t *testing.T) {
h := serveTree(t)
write(t, h.in("gone.txt"), "bye\n")
diff := "--- a/gone.txt\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-bye\n"
if got := h.call(&req{op: opPatch, id: 1, data: []byte(diff)}); got.err != "" {
t.Fatalf("patch: %s", got.err)
}
if _, err := os.Stat(h.in("gone.txt")); !os.IsNotExist(err) {
t.Fatalf("the file is still there: %v", err)
}
if left, _ := filepath.Glob(h.in(".execd*")); len(left) != 0 {
t.Fatalf("staged files left behind: %v", left)
}
if got := h.call(&req{op: opPatch, id: 2, data: []byte(diff)}); got.err == "" {
t.Fatal("a diff deleted a file that was not there")
}
}