package fcproc import ( "context" "errors" "log/slog" "os" "path/filepath" "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") } } 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 }