package daemon import ( "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "testing" "time" ) func TestFindLocalDirectoryAssignment(t *testing.T) { const thisDaemon = "d-mine" otherDaemon := "d-other" mkRef := func(t *testing.T, ref localDirectoryRef) json.RawMessage { t.Helper() raw, err := json.Marshal(ref) if err != nil { t.Fatalf("marshal: %v", err) } return raw } tmp := t.TempDir() t.Run("no resources returns nil", func(t *testing.T) { got, err := findLocalDirectoryAssignment(nil, thisDaemon) if err != nil || got != nil { t.Fatalf("expected (nil, nil), got (%+v, %v)", got, err) } }) t.Run("other daemon is skipped", func(t *testing.T) { got, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: otherDaemon})}, }, thisDaemon) if err != nil || got != nil { t.Fatalf("expected (nil, nil), got (%+v, %v)", got, err) } }) t.Run("non-matching type is skipped", func(t *testing.T) { got, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: "github_repo", ResourceRef: json.RawMessage(`{"url":"https://x"}`)}, }, thisDaemon) if err != nil || got != nil { t.Fatalf("expected (nil, nil), got (%+v, %v)", got, err) } }) t.Run("matching daemon returns assignment", func(t *testing.T) { got, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: thisDaemon})}, }, thisDaemon) if err != nil { t.Fatalf("err: %v", err) } if got == nil { t.Fatalf("expected assignment, got nil") } if got.AbsPath != filepath.Clean(tmp) { t.Errorf("AbsPath = %q, want %q", got.AbsPath, filepath.Clean(tmp)) } if got.RealPath == "" { t.Errorf("RealPath empty") } }) t.Run("missing daemon_id is rejected", func(t *testing.T) { _, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp})}, }, thisDaemon) if err == nil { t.Fatalf("expected error for missing daemon_id") } }) t.Run("relative path is rejected", func(t *testing.T) { _, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: "relative/path", DaemonID: thisDaemon})}, }, thisDaemon) if err == nil { t.Fatalf("expected error for relative path") } }) t.Run("malformed ref json fails", func(t *testing.T) { _, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: json.RawMessage(`{not json`)}, }, thisDaemon) if err == nil { t.Fatalf("expected error for malformed json") } }) t.Run("two local_directory rows on this daemon fail fast", func(t *testing.T) { // Server-side findLocalDirectoryConflict enforces one // local_directory per (project, daemon). If two rows are // somehow present (older API client, direct DB writes), the // daemon must refuse to guess which directory to execute in. tmp2 := t.TempDir() _, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: thisDaemon})}, {ID: "r2", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp2, DaemonID: thisDaemon})}, }, thisDaemon) if err == nil { t.Fatalf("expected error for two local_directory rows pinned to this daemon") } if !strings.Contains(err.Error(), "multiple local_directory") { t.Errorf("error %q did not mention multiple local_directory", err) } }) t.Run("local_directory rows on different daemons coexist", func(t *testing.T) { // Different daemons MAY each carry one row — same path on // different machines is allowed; this daemon only resolves // its own row regardless of how many other-daemon rows are // in the list. got, err := findLocalDirectoryAssignment([]ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: thisDaemon})}, {ID: "r2", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: otherDaemon})}, }, thisDaemon) if err != nil { t.Fatalf("err: %v", err) } if got == nil { t.Fatalf("expected assignment, got nil") } }) } func TestValidateLocalPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("blacklist constants are POSIX-only in this test") } dir := t.TempDir() t.Run("accepts a writable directory", func(t *testing.T) { if err := validateLocalPath(dir); err != nil { t.Errorf("unexpected: %v", err) } }) t.Run("rejects relative path", func(t *testing.T) { if err := validateLocalPath("relative"); err == nil { t.Errorf("expected error") } }) t.Run("rejects empty path", func(t *testing.T) { if err := validateLocalPath(""); err == nil { t.Errorf("expected error") } }) t.Run("rejects system roots", func(t *testing.T) { for _, banned := range []string{"/", "/Users", "/home"} { if err := validateLocalPath(banned); err == nil { t.Errorf("expected error for %q", banned) } } }) t.Run("rejects the user home directory", func(t *testing.T) { home, err := os.UserHomeDir() if err != nil || home == "" { t.Skip("no home dir") } if err := validateLocalPath(home); err == nil { t.Errorf("expected error for $HOME") } }) t.Run("rejects missing path", func(t *testing.T) { missing := filepath.Join(dir, "does-not-exist") if err := validateLocalPath(missing); err == nil { t.Errorf("expected error") } }) t.Run("rejects a regular file", func(t *testing.T) { f := filepath.Join(dir, "afile") if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { t.Fatalf("write: %v", err) } if err := validateLocalPath(f); err == nil { t.Errorf("expected error") } }) t.Run("rejects an unwritable directory", func(t *testing.T) { // chmod-based unwritable is unreliable as root; skip when uid==0. if os.Getuid() == 0 { t.Skip("test cannot run as root; chmod is a no-op") } ro := filepath.Join(dir, "ro") if err := os.Mkdir(ro, 0o555); err != nil { t.Fatalf("mkdir: %v", err) } t.Cleanup(func() { _ = os.Chmod(ro, 0o755) }) if err := validateLocalPath(ro); err == nil { t.Errorf("expected error for read-only directory") } }) t.Run("rejects a symlink pointing at the user home", func(t *testing.T) { home, err := os.UserHomeDir() if err != nil || home == "" { t.Skip("no home dir") } link := filepath.Join(dir, "home-link") if err := os.Symlink(home, link); err != nil { t.Fatalf("symlink: %v", err) } // The literal cleaned path is dir/home-link, which is NOT in the // blacklist. Without the realpath check this used to pass. err = validateLocalPath(link) if err == nil { t.Fatal("expected error for symlink pointing at $HOME") } if !strings.Contains(err.Error(), "user's home directory") { t.Errorf("error %q did not flag the home-dir reason", err.Error()) } }) t.Run("rejects a symlink pointing at a system root", func(t *testing.T) { link := filepath.Join(dir, "root-link") // Pick a banned system root that's predictably present on the // host. /Users on macOS; /home on Linux. Fall back to /etc which // is in the blacklist and exists on both. target := "/etc" if err := os.Symlink(target, link); err != nil { t.Fatalf("symlink: %v", err) } err := validateLocalPath(link) if err == nil { t.Fatal("expected error for symlink pointing at a system root") } if !strings.Contains(err.Error(), "protected system root") { t.Errorf("error %q did not flag the system-root reason", err.Error()) } }) t.Run("accepts a symlink to a non-blacklisted directory", func(t *testing.T) { target := filepath.Join(dir, "real-proj") if err := os.Mkdir(target, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } link := filepath.Join(dir, "proj-link") if err := os.Symlink(target, link); err != nil { t.Fatalf("symlink: %v", err) } if err := validateLocalPath(link); err != nil { t.Errorf("symlink to a regular directory should pass, got %v", err) } }) // macOS aliases /tmp, /etc, /var to /private/{tmp,etc,var} via OS-level // symlinks. A user typing the canonical /private/... form in the picker // would pass the literal blacklist (it doesn't contain /private/tmp) // and EvalSymlinks would be a no-op (the input is already canonical), // so the old "only re-check when realPath != absPath" gate skipped it. // Cover the regression so the realpath blacklist always runs. t.Run("rejects canonical macOS /private/{tmp,etc,var}", func(t *testing.T) { if runtime.GOOS != "darwin" { t.Skip("macOS-only: /private/* aliases don't exist elsewhere") } for _, p := range []string{"/private/tmp", "/private/etc", "/private/var"} { if _, statErr := os.Stat(p); statErr != nil { t.Logf("skipping %q: %v", p, statErr) continue } err := validateLocalPath(p) if err == nil { t.Errorf("expected error for canonical %q", p) continue } if !strings.Contains(err.Error(), "protected system root") { t.Errorf("error %q for %q did not flag the system-root reason", err.Error(), p) } } }) } // TestIsDriveRoot covers the Windows drive-root generalisation. Static // enumeration in the old blacklist (C..F) missed mounts at G:\ and up; the // new check goes through filepath.VolumeName so any drive letter (and UNC // roots) is rejected. func TestIsDriveRoot(t *testing.T) { if runtime.GOOS != "windows" { // filepath.VolumeName returns "" on POSIX, so isDriveRoot always // returns false off Windows. The semantic contract is enforced by // the early `runtime.GOOS != "windows"` guard; the case table // below is only meaningful on a Windows runner. t.Skip("windows-only behaviour") } cases := []struct { p string want bool }{ {`C:\`, true}, {`G:\`, true}, {`Z:\`, true}, {`C:/`, true}, {`C:`, true}, {`\\srv\share`, true}, {`\\srv\share\`, true}, {`C:\Users`, false}, {`D:\proj`, false}, {`C:\Users\me\code`, false}, } for _, c := range cases { if got := isDriveRoot(c.p); got != c.want { t.Errorf("isDriveRoot(%q) = %v, want %v", c.p, got, c.want) } } } func TestLocalPathLockerSerializes(t *testing.T) { locker := NewLocalPathLocker() const path = "/some/path" rel1, err := locker.Acquire(context.Background(), path, "task-1", nil) if err != nil { t.Fatalf("acquire 1: %v", err) } if got := locker.Holder(path); got != "task-1" { t.Errorf("holder = %q, want task-1", got) } // task-2 must wait, with onWait fired and the holder reported. var waitCalls atomic.Int32 var sawHolder atomic.Value done := make(chan error, 1) go func() { rel, err := locker.Acquire(context.Background(), path, "task-2", func(holder string) { waitCalls.Add(1) sawHolder.Store(holder) }) if err != nil { done <- err return } if got := locker.Holder(path); got != "task-2" { done <- errorsNew("holder after handover = " + got) return } rel() done <- nil }() // give the goroutine time to enter the wait deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) && waitCalls.Load() == 0 { time.Sleep(5 * time.Millisecond) } if waitCalls.Load() != 1 { t.Fatalf("onWait calls = %d, want 1", waitCalls.Load()) } if got := sawHolder.Load(); got != "task-1" { t.Errorf("onWait holder = %v, want task-1", got) } rel1() select { case err := <-done: if err != nil { t.Fatalf("waiter result: %v", err) } case <-time.After(2 * time.Second): t.Fatal("waiter never woke") } if got := locker.Holder(path); got != "" { t.Errorf("holder after release = %q, want empty", got) } } func TestLocalPathLockerCtxCancel(t *testing.T) { locker := NewLocalPathLocker() const path = "/some/path" rel1, err := locker.Acquire(context.Background(), path, "task-1", nil) if err != nil { t.Fatalf("acquire 1: %v", err) } defer rel1() ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() _, err = locker.Acquire(ctx, path, "task-2", nil) if err == nil { t.Fatalf("expected ctx error, got nil") } if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("err = %v, want DeadlineExceeded", err) } } func TestLocalPathLockerDistinctPathsParallel(t *testing.T) { locker := NewLocalPathLocker() rel1, err := locker.Acquire(context.Background(), "/a", "task-1", nil) if err != nil { t.Fatalf("acquire 1: %v", err) } defer rel1() // Different path must not block. done := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() rel2, err := locker.Acquire(context.Background(), "/b", "task-2", nil) if err != nil { t.Errorf("acquire 2: %v", err) return } rel2() close(done) }() select { case <-done: case <-time.After(time.Second): t.Fatal("acquire on distinct path blocked") } wg.Wait() } // errorsNew is a tiny helper so the goroutine above can return a typed error // without importing errors / fmt at the call site. func errorsNew(msg string) error { return &waiterError{msg: msg} } type waiterError struct{ msg string } func (e *waiterError) Error() string { return e.msg } // TestAcquireLocalDirectoryLock_CancelDuringWait covers the gap between // dispatch and StartTask: while the path mutex is contended, the main // per-task cancellation watcher hasn't started yet. If the issue is // cancelled (or the task row is reassigned / deleted) during the wait, // the daemon must notice promptly and bail — otherwise the slot stays // pinned by a phantom waiter for the full lifetime of the holder. func TestAcquireLocalDirectoryLock_CancelDuringWait(t *testing.T) { t.Parallel() dir := t.TempDir() // valid, writable, non-blacklisted // Pre-claim must use the same key the production path computes, which // is the symlink-resolved realpath. On macOS, /tmp/... resolves to // /private/tmp/..., so a literal preclaim with `dir` would miss the // production key and the new acquire would win on the fast path. realDir, err := filepath.EvalSymlinks(dir) if err != nil { t.Fatalf("evalsymlinks: %v", err) } // Server-side state for the fake. Mark the task cancelled only after // we've seen the daemon call wait-local-directory, so the test can // assert the watcher reacted to the post-park cancel rather than // reading stale state on the very first poll. var ( parked atomic.Bool waitCall atomic.Int32 ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/wait-local-directory"): waitCall.Add(1) parked.Store(true) w.WriteHeader(http.StatusOK) case strings.HasSuffix(r.URL.Path, "/status"): w.Header().Set("Content-Type", "application/json") if parked.Load() { _, _ = w.Write([]byte(`{"status":"cancelled"}`)) } else { _, _ = w.Write([]byte(`{"status":"running"}`)) } default: // We don't expect /fail in the cancel path — assert that // by failing loud if it gets called. t.Errorf("unexpected daemon call: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusOK) } })) t.Cleanup(srv.Close) const daemonID = "d-test" const heldByTaskID = "task-holder" const newTaskID = "task-waiter" locker := NewLocalPathLocker() // Pre-claim the lock so the new task has to wait. Use the resolved // realpath as the key to match findLocalDirectoryAssignment. release, err := locker.Acquire(context.Background(), realDir, heldByTaskID, nil) if err != nil { t.Fatalf("preclaim acquire: %v", err) } t.Cleanup(release) d := &Daemon{ client: NewClient(srv.URL), logger: slog.Default(), localPathLocks: locker, cancelPollInterval: 10 * time.Millisecond, cfg: Config{DaemonID: daemonID}, } ref, err := json.Marshal(localDirectoryRef{LocalPath: dir, DaemonID: daemonID}) if err != nil { t.Fatalf("marshal ref: %v", err) } task := Task{ ID: newTaskID, ProjectResources: []ProjectResourceData{ {ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: ref}, }, } type result struct { release func() abort bool } done := make(chan result, 1) go func() { rel, abort := d.acquireLocalDirectoryLockIfNeeded(context.Background(), task, slog.Default()) done <- result{release: rel, abort: abort} }() select { case got := <-done: if !got.abort { t.Fatal("expected abort=true after server-side cancel, got abort=false") } if got.release != nil { t.Fatal("expected nil release on cancel, got a non-nil callback") } case <-time.After(2 * time.Second): t.Fatal("acquireLocalDirectoryLockIfNeeded blocked past 2s — cancel was not observed during wait") } if got := waitCall.Load(); got != 1 { t.Errorf("wait-local-directory calls = %d, want 1", got) } }