package fcproc import ( "context" "errors" "log/slog" "os" "path/filepath" "strings" "testing" "time" ) // scriptedRunner is a minimal Runner that records every call and // plays back a pre-scripted sequence of (name, args, out, err) // steps. Failing to match or running past the script fails the // test. Mirrors the pattern from internal/daemon/snapshot_test.go // but lives here because fcproc is a leaf package — it can't import // its parent's test helpers. type scriptedRunner struct { t *testing.T runs []scriptedCall sudos []scriptedCall } type scriptedCall struct { matchName string // empty for RunSudo (sudo has no distinct name arg) matchArgs []string // nil means "don't care" out []byte err error } func (r *scriptedRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { r.t.Helper() if len(r.runs) == 0 { r.t.Fatalf("unexpected Run(%q, %v)", name, args) } step := r.runs[0] r.runs = r.runs[1:] if step.matchName != "" && step.matchName != name { r.t.Fatalf("Run name = %q, want %q", name, step.matchName) } return step.out, step.err } func (r *scriptedRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) { r.t.Helper() if len(r.sudos) == 0 { r.t.Fatalf("unexpected RunSudo(%v)", args) } step := r.sudos[0] r.sudos = r.sudos[1:] return step.out, step.err } // TestWaitForPathReturnsDeadlineExceededWhenSocketNeverAppears pins // the timeout branch of waitForPath. If this drifts, every callsite // that wraps it (EnsureSocketAccess on the firecracker API + // vsock sockets) loses its bounded wait. func TestWaitForPathReturnsDeadlineExceededWhenSocketNeverAppears(t *testing.T) { missing := filepath.Join(t.TempDir(), "never-created.sock") start := time.Now() err := waitForPath(context.Background(), missing, 150*time.Millisecond, "api socket") elapsed := time.Since(start) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("err = %v, want wrapped context.DeadlineExceeded", err) } if !contains(err.Error(), "api socket") { t.Fatalf("err = %v, want label 'api socket' in message", err) } // Timeout should fire close to the configured budget, not zero // (tight-loop regression) and not way over (missing select // regression). The 100ms poll tick plus the initial stat makes // the lower bound noisy; check we at least waited a tick. if elapsed < 90*time.Millisecond { t.Fatalf("returned after %s; waitForPath exited before its timeout budget", elapsed) } } // TestWaitForPathReturnsOnceSocketAppears pins the happy path: // when the file materialises mid-wait, the function returns nil // without having to walk to its deadline. func TestWaitForPathReturnsOnceSocketAppears(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "will-appear.sock") go func() { time.Sleep(50 * time.Millisecond) _ = os.WriteFile(socketPath, []byte{}, 0o600) }() if err := waitForPath(context.Background(), socketPath, 2*time.Second, "api socket"); err != nil { t.Fatalf("waitForPath: %v", err) } } // TestWaitForPathRespectsContextCancellation pins the ctx.Done() // branch — a canceled request must not be blocked by the poll // interval. func TestWaitForPathRespectsContextCancellation(t *testing.T) { missing := filepath.Join(t.TempDir(), "never.sock") ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(30 * time.Millisecond) cancel() }() err := waitForPath(ctx, missing, 5*time.Second, "api socket") if !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled when ctx is cancelled mid-wait", err) } } // TestEnsureSocketAccessChmodFailureBubbles verifies the chmod step // fails fast before any ownership handoff. Once chown runs, the // bounded helper no longer owns the socket and can't tighten its mode // without CAP_FOWNER, so the order matters. func TestEnsureSocketAccessChmodFailureBubbles(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "present.sock") if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } chmodErr := errors.New("sudo chmod failed") runner := &scriptedRunner{ t: t, sudos: []scriptedCall{{err: chmodErr}}, } mgr := New(runner, Config{}, slog.Default()) err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket") if !errors.Is(err, chmodErr) { t.Fatalf("err = %v, want chmod error", err) } // chown must not have been attempted. if len(runner.sudos) != 0 { t.Fatalf("chown was attempted after chmod failed: %d sudo calls left", len(runner.sudos)) } } // TestEnsureSocketAccessChownFailureBubbles verifies the ownership // handoff still surfaces errors after chmod succeeds. func TestEnsureSocketAccessChownFailureBubbles(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "present.sock") if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } chownErr := errors.New("sudo chown failed") runner := &scriptedRunner{ t: t, sudos: []scriptedCall{ {}, // chmod succeeds {err: chownErr}, // chown fails }, } mgr := New(runner, Config{}, slog.Default()) err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket") if !errors.Is(err, chownErr) { t.Fatalf("err = %v, want chown error", err) } } // TestEnsureSocketAccessTimesOutBeforeTouchingRunner pins the // ordering contract: if waitForPath never sees the socket, the // sudo commands must not run. Running chown/chmod against a // non-existent path would just noise the logs. func TestEnsureSocketAccessTimesOutBeforeTouchingRunner(t *testing.T) { missing := filepath.Join(t.TempDir(), "never.sock") runner := &scriptedRunner{t: t} // no scripted calls — any runner invocation fails the test mgr := New(runner, Config{}, slog.Default()) // EnsureSocketAccess's waitForPath has a hardcoded 5s timeout, // and we can't inject a shorter one without widening the API. // Use a short context instead — cancellation short-circuits // waitForPath via the ctx.Done() branch. ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) defer cancel() err := mgr.EnsureSocketAccess(ctx, missing, "api socket") if err == nil { t.Fatal("EnsureSocketAccess: want error when socket never appears") } } // TestEnsureSocketAccessForAsyncReturnsImmediatelyWhenNoPaths pins the // fast-path: callers can hand the helper an empty list (e.g. when VSockPath // is unset) and get a no-op channel back without spinning a goroutine. func TestEnsureSocketAccessForAsyncReturnsImmediatelyWhenNoPaths(t *testing.T) { runner := &scriptedRunner{t: t} // any runner call would fail the test mgr := New(runner, Config{}, slog.Default()) done := mgr.EnsureSocketAccessForAsync(context.Background(), []string{"", " "}, 1000, 1000) select { case err := <-done: if err != nil { t.Fatalf("got %v, want nil for empty input", err) } case <-time.After(time.Second): t.Fatal("EnsureSocketAccessForAsync did not signal completion") } } // TestEnsureSocketAccessForAsyncWaitsForSocketThenChowns pins the boot-time // race fix: while Machine.Start spins up firecracker, the helper polls for the // socket and runs chmod + chown the moment it appears. If this drifts, the // SDK's HTTP probe gets EACCES on a root-owned socket and Start times out. func TestEnsureSocketAccessForAsyncWaitsForSocketThenChowns(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "delayed.sock") go func() { time.Sleep(50 * time.Millisecond) _ = os.WriteFile(socketPath, []byte{}, 0o600) }() runner := &scriptedRunner{ t: t, sudos: []scriptedCall{ {}, // chmod 600 {}, // chown uid:gid }, } mgr := New(runner, Config{}, slog.Default()) done := mgr.EnsureSocketAccessForAsync(context.Background(), []string{socketPath}, 4242, 4242) select { case err := <-done: if err != nil { t.Fatalf("EnsureSocketAccessForAsync: %v", err) } case <-time.After(2 * time.Second): t.Fatal("EnsureSocketAccessForAsync did not signal completion") } if len(runner.sudos) != 0 { t.Fatalf("expected both chmod and chown to run, %d sudo calls remaining", len(runner.sudos)) } } // recordingRunner captures every Run/RunSudo invocation's full // argv. Used to assert that ensureSocketAccessFor's fallback path // passes `chown -h` rather than the symlink-following plain `chown`. type recordingRunner struct { sudos [][]string runs [][]string } func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { r.runs = append(r.runs, append([]string{name}, args...)) return nil, nil } func (r *recordingRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) { r.sudos = append(r.sudos, append([]string(nil), args...)) return nil, nil } // TestCleanupJailerChrootRejectsSymlink pins the TOCTOU-closing // fcproc-side check: even if a daemon-uid attacker somehow bypasses // the helper handler's validateNotSymlink (or races it), the cleanup // itself refuses a symlinked path before any umount/rm shells. func TestCleanupJailerChrootRejectsSymlink(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "real") if err := os.Mkdir(target, 0o700); err != nil { t.Fatalf("mkdir target: %v", err) } link := filepath.Join(dir, "link") if err := os.Symlink(target, link); err != nil { t.Fatalf("symlink: %v", err) } // scriptedRunner with no scripted calls — any shell invocation // trips r.t.Fatalf, proving rejection happened before umount/rm. runner := &scriptedRunner{t: t} mgr := New(runner, Config{}, slog.Default()) if err := mgr.CleanupJailerChroot(context.Background(), link); err == nil { t.Fatal("CleanupJailerChroot(symlink) succeeded, want error") } } // TestCleanupJailerChrootRejectsIntermediateSymlink covers the // `/jail/firecracker/ → /` shape: the leaf "/root" component // is a real directory inside the redirected target, but EvalSymlinks // resolves to a different path so we still bail. func TestCleanupJailerChrootRejectsIntermediateSymlink(t *testing.T) { dir := t.TempDir() realParent := filepath.Join(dir, "real-parent") if err := os.MkdirAll(filepath.Join(realParent, "root"), 0o700); err != nil { t.Fatalf("mkdir real: %v", err) } linkParent := filepath.Join(dir, "link-parent") if err := os.Symlink(realParent, linkParent); err != nil { t.Fatalf("symlink: %v", err) } chrootViaSymlink := filepath.Join(linkParent, "root") runner := &scriptedRunner{t: t} mgr := New(runner, Config{}, slog.Default()) if err := mgr.CleanupJailerChroot(context.Background(), chrootViaSymlink); err == nil { t.Fatal("CleanupJailerChroot(symlinked-parent) succeeded, want error") } } // TestCleanupJailerChrootHappyPathWithoutMounts pins the no-leak case: // when findmnt reports zero mounts under the chroot, the cleanup // skips straight to `sudo rm -rf` without invoking umount2 / sudo // umount at all. Regression guard for the umount2 rewrite — if the // new logic leaks an extra runner call here, this test will fail. func TestCleanupJailerChrootHappyPathWithoutMounts(t *testing.T) { dir := t.TempDir() chroot := filepath.Join(dir, "root") if err := os.Mkdir(chroot, 0o700); err != nil { t.Fatalf("mkdir chroot: %v", err) } runner := &scriptedRunner{ t: t, runs: []scriptedCall{ // First mountsUnder() — pre-detach. Empty stdout = no mounts. {matchName: "findmnt", out: nil}, // Second mountsUnder() — post-detach guard. Same. {matchName: "findmnt", out: nil}, }, // sudo rm -rf -- chroot. sudos: []scriptedCall{{}}, } mgr := New(runner, Config{}, slog.Default()) if err := mgr.CleanupJailerChroot(context.Background(), chroot); err != nil { t.Fatalf("CleanupJailerChroot: %v", err) } if len(runner.runs) != 0 { t.Fatalf("findmnt scripted calls left over: %d", len(runner.runs)) } if len(runner.sudos) != 0 { t.Fatalf("sudo scripted calls left over: %d", len(runner.sudos)) } } // TestCleanupJailerChrootDetachesMountsDeepestFirst pins the ordering // contract for the umount2 rewrite: child mounts come off before // parents, otherwise the parent unmount would race against in-use // children. The non-root code path shells `sudo umount --lazy`, which // the recording runner captures so we can assert order + the --lazy // flag. func TestCleanupJailerChrootDetachesMountsDeepestFirst(t *testing.T) { if os.Geteuid() == 0 { t.Skip("euid 0 takes the umount2 syscall branch; this test exercises the sudo fallback") } dir := t.TempDir() chroot := filepath.Join(dir, "root") if err := os.Mkdir(chroot, 0o700); err != nil { t.Fatalf("mkdir chroot: %v", err) } parent := chroot child := filepath.Join(chroot, "lib") deep := filepath.Join(child, "deep") findmntOut := []byte(strings.Join([]string{parent, child, deep}, "\n")) runner := &mountRecordingRunner{findmntOut: findmntOut} mgr := New(runner, Config{}, slog.Default()) if err := mgr.CleanupJailerChroot(context.Background(), chroot); err != nil { t.Fatalf("CleanupJailerChroot: %v", err) } // Three umount + final rm -rf. The umount targets must be deep, // child, parent in that order. wantTargets := []string{deep, child, parent} if len(runner.umountTargets) != len(wantTargets) { t.Fatalf("umount calls = %v, want %d", runner.umountTargets, len(wantTargets)) } for i, want := range wantTargets { if runner.umountTargets[i] != want { t.Fatalf("umount[%d] = %q, want %q", i, runner.umountTargets[i], want) } } if !runner.lazyFlagSeen { t.Fatalf("expected umount --lazy on the sudo branch, args = %v", runner.umountArgs) } if !runner.rmCalled { t.Fatal("rm -rf was never invoked after the umount sweep") } } // mountRecordingRunner stubs out findmnt + sudo for the cleanup path: // the first findmnt call returns the canned mount list (pre-detach), // subsequent calls return empty to simulate the kernel having dropped // each mount as we asked. sudo umount/rm calls are captured and // answer success. type mountRecordingRunner struct { findmntOut []byte findmntCalls int umountTargets []string umountArgs [][]string lazyFlagSeen bool rmCalled bool } func (r *mountRecordingRunner) Run(_ context.Context, name string, _ ...string) ([]byte, error) { if name == "findmnt" { r.findmntCalls++ if r.findmntCalls == 1 { return r.findmntOut, nil } return nil, nil } return nil, nil } func (r *mountRecordingRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) { if len(args) == 0 { return nil, nil } switch args[0] { case "umount": // Last arg is the target. Earlier args are flags. if len(args) >= 2 { r.umountTargets = append(r.umountTargets, args[len(args)-1]) } r.umountArgs = append(r.umountArgs, append([]string(nil), args...)) for _, a := range args[1 : len(args)-1] { if a == "--lazy" || a == "-l" { r.lazyFlagSeen = true } } case "rm": r.rmCalled = true } return nil, nil } // TestEnsureSocketAccessSudoBranchUsesChownNoFollow pins the // symlink-defence on the local-priv (non-root) path: a follow-symlink // chown on a daemon-uid attacker-planted symlink is the same arbitrary // file-ownership primitive we close in the root branch via // O_PATH|O_NOFOLLOW. Test only runs as non-root (the syscall branch is // taken when euid == 0, which CI doesn't see). func TestEnsureSocketAccessSudoBranchUsesChownNoFollow(t *testing.T) { if os.Geteuid() == 0 { t.Skip("euid 0 takes the syscall branch; the sudo branch is only reachable as a regular user") } socketPath := filepath.Join(t.TempDir(), "present.sock") if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } runner := &recordingRunner{} mgr := New(runner, Config{}, slog.Default()) if err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket"); err != nil { t.Fatalf("EnsureSocketAccess: %v", err) } if len(runner.sudos) != 2 { t.Fatalf("got %d sudo calls, want 2 (chmod, chown)", len(runner.sudos)) } chown := runner.sudos[1] if len(chown) < 2 || chown[0] != "chown" { t.Fatalf("second sudo call = %v, want chown", chown) } hasNoFollow := false for _, arg := range chown[1:] { if arg == "-h" { hasNoFollow = true break } } if !hasNoFollow { t.Fatalf("chown args = %v, missing the -h symlink-no-follow flag", chown) } } func contains(s, sub string) bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return true } } return false }