| 1 | package jobs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "io" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/event" |
| 13 | "reasonix/internal/store" |
| 14 | ) |
| 15 | |
| 16 | // captureSink records every event the manager emits so tests can assert that |
| 17 | // the validation-failure path emitted the expected warning. |
| 18 | type captureSink struct { |
| 19 | mu sync.Mutex |
| 20 | events []event.Event |
| 21 | } |
| 22 | |
| 23 | func (s *captureSink) Emit(ev event.Event) { |
| 24 | s.mu.Lock() |
| 25 | defer s.mu.Unlock() |
| 26 | s.events = append(s.events, ev) |
| 27 | } |
| 28 | |
| 29 | func (s *captureSink) texts() []string { |
| 30 | s.mu.Lock() |
| 31 | defer s.mu.Unlock() |
| 32 | out := make([]string, 0, len(s.events)) |
| 33 | for _, ev := range s.events { |
| 34 | if ev.Text != "" { |
| 35 | out = append(out, ev.Text) |
| 36 | } |
| 37 | } |
| 38 | return out |
| 39 | } |
| 40 | |
| 41 | func (s *captureSink) hasText(needle string) bool { |
| 42 | for _, t := range s.texts() { |
| 43 | if strings.Contains(t, needle) { |
| 44 | return true |
| 45 | } |
| 46 | } |
| 47 | return false |
| 48 | } |
| 49 | |
| 50 | // TestValidateTrustedSessionPath covers the defense-in-depth syntax validator |
| 51 | // for transcript paths supplied by the trusted store/controller layer. It is |
| 52 | // deliberately not a trusted-root containment check. |
| 53 | func TestValidateTrustedSessionPath(t *testing.T) { |
| 54 | cases := []struct { |
| 55 | name string |
| 56 | input string |
| 57 | wantErr bool |
| 58 | }{ |
| 59 | // Empty is rejected (caller must provide a transcript path). |
| 60 | {"empty is rejected", "", true}, |
| 61 | // Absolute and relative transcript paths in normal use. |
| 62 | {"absolute posix path", "/home/u/.reasonix/sessions/abc.jsonl", false}, |
| 63 | {"absolute windows path", `C:\Users\me\.reasonix\sessions\abc.jsonl`, false}, |
| 64 | {"workspace-relative", "sessions/abc.jsonl", false}, |
| 65 | // Filenames with a hidden segment are still legitimate. |
| 66 | {"..hidden is allowed", "/home/u/.reasonix/..hidden.jsonl", false}, |
| 67 | {"triple-dot is allowed", "/home/u/...jsonl", false}, |
| 68 | // Trusted paths keep their host-path semantics. This validator is not a |
| 69 | // trusted-root containment boundary. |
| 70 | {"dotdot with separators", "/safe/../session.jsonl", false}, |
| 71 | {"leading dotdot", "../sessions/abc.jsonl", false}, |
| 72 | {"trailing dotdot", "/safe/dir/..", false}, |
| 73 | {"windows backslashes", `C:\safe\..\sessions\abc.jsonl`, false}, |
| 74 | // Control characters and NUL. |
| 75 | {"NUL byte", "/safe/abc\x00.jsonl", true}, |
| 76 | {"newline", "/safe/abc\n.jsonl", true}, |
| 77 | {"tab", "/safe/abc\t.jsonl", true}, |
| 78 | {"DEL char", "/safe/abc\x7f.jsonl", true}, |
| 79 | } |
| 80 | for _, tc := range cases { |
| 81 | t.Run(tc.name, func(t *testing.T) { |
| 82 | err := validateTrustedSessionPath(tc.input) |
| 83 | if tc.wantErr && err == nil { |
| 84 | t.Fatalf("validateTrustedSessionPath(%q) = nil, want error", tc.input) |
| 85 | } |
| 86 | if !tc.wantErr && err != nil { |
| 87 | t.Fatalf("validateTrustedSessionPath(%q) = %v, want nil", tc.input, err) |
| 88 | } |
| 89 | }) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // TestSetActiveSessionPath_AcceptsTrustedDotDotPath preserves valid relative |
| 94 | // transcript spellings used by callers such as headless --resume. |
| 95 | func TestSetActiveSessionPath_AcceptsTrustedDotDotPath(t *testing.T) { |
| 96 | root := t.TempDir() |
| 97 | intermediate := filepath.Join(root, "intermediate") |
| 98 | if err := os.MkdirAll(intermediate, 0o700); err != nil { |
| 99 | t.Fatalf("create intermediate dir: %v", err) |
| 100 | } |
| 101 | sink := &captureSink{} |
| 102 | m := NewManager(sink) |
| 103 | defer m.Close() |
| 104 | |
| 105 | // Raw concatenation preserves the trusted `..` spelling that filepath.Join |
| 106 | // would otherwise clean before it reaches SetActiveSessionPath. |
| 107 | sessionPath := intermediate + string(os.PathSeparator) + ".." + |
| 108 | string(os.PathSeparator) + "session.jsonl" |
| 109 | |
| 110 | m.SetActiveSessionPath("session-a", sessionPath) |
| 111 | |
| 112 | m.mu.Lock() |
| 113 | active := m.active |
| 114 | cached := m.artifactDirs["session-a"] |
| 115 | m.mu.Unlock() |
| 116 | if active != "session-a" { |
| 117 | t.Fatalf("active = %q, want %q", active, "session-a") |
| 118 | } |
| 119 | wantDir := store.SessionJobsDir(sessionPath) |
| 120 | if cached != wantDir { |
| 121 | t.Fatalf("cached dir = %q, want %q", cached, wantDir) |
| 122 | } |
| 123 | if sink.hasText("Ignoring SetActiveSessionPath with invalid session path") { |
| 124 | t.Fatalf("trusted dotdot path unexpectedly emitted a warning: %v", sink.texts()) |
| 125 | } |
| 126 | |
| 127 | j := m.StartForSession("session-a", "bash", "trusted relative path", func(_ context.Context, _ io.Writer) (string, error) { |
| 128 | return "ok", nil |
| 129 | }) |
| 130 | <-j.done |
| 131 | if j.artifactErr != "" { |
| 132 | t.Fatalf("artifactErr = %q, want empty", j.artifactErr) |
| 133 | } |
| 134 | if got, want := filepath.Clean(filepath.Dir(j.artifactPath)), filepath.Join(root, "session.jobs"); got != want { |
| 135 | t.Fatalf("artifact dir = %q, want %q", got, want) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // TestSetActiveSessionPath_InvalidPathUpdatesActiveAndClearsBinding verifies |
| 140 | // that filesystem rejection does not leave lifecycle notices or future jobs |
| 141 | // attached to the previous session/path. |
| 142 | func TestSetActiveSessionPath_InvalidPathUpdatesActiveAndClearsBinding(t *testing.T) { |
| 143 | sink := &captureSink{} |
| 144 | m := NewManager(sink) |
| 145 | defer m.Close() |
| 146 | |
| 147 | m.SetActiveSessionPath("session-x", filepath.Join(t.TempDir(), "old.jsonl")) |
| 148 | m.SetActiveSession("old-session") |
| 149 | m.SetActiveSessionPath("session-x", filepath.Join(t.TempDir(), "bad\npath.jsonl")) |
| 150 | |
| 151 | m.mu.Lock() |
| 152 | active := m.active |
| 153 | _, hasCached := m.artifactDirs["session-x"] |
| 154 | _, loaded := m.loaded["session-x"] |
| 155 | m.mu.Unlock() |
| 156 | if active != "session-x" { |
| 157 | t.Fatalf("active = %q, want %q", active, "session-x") |
| 158 | } |
| 159 | if hasCached { |
| 160 | t.Fatal("artifactDirs retained the stale binding for a rejected sessionPath") |
| 161 | } |
| 162 | if loaded { |
| 163 | t.Fatal("loaded retained the stale binding for a rejected sessionPath") |
| 164 | } |
| 165 | if !sink.hasText("Ignoring SetActiveSessionPath with invalid session path") { |
| 166 | t.Fatalf("expected warning emission, got events: %v", sink.texts()) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // TestSetActiveSessionPath_EmptyPathUpdatesActiveOnly preserves the legacy |
| 171 | // active-session update used before a persistent transcript path is available. |
| 172 | func TestSetActiveSessionPath_EmptyPathUpdatesActiveOnly(t *testing.T) { |
| 173 | sink := &captureSink{} |
| 174 | m := NewManager(sink) |
| 175 | defer m.Close() |
| 176 | |
| 177 | m.SetActiveSessionPath("session-active", "") |
| 178 | |
| 179 | m.mu.Lock() |
| 180 | active := m.active |
| 181 | _, hasCached := m.artifactDirs["session-active"] |
| 182 | m.mu.Unlock() |
| 183 | if active != "session-active" { |
| 184 | t.Fatalf("active = %q, want %q", active, "session-active") |
| 185 | } |
| 186 | if hasCached { |
| 187 | t.Fatal("empty sessionPath unexpectedly populated artifactDirs") |
| 188 | } |
| 189 | if sink.hasText("Ignoring SetActiveSessionPath with invalid session path") { |
| 190 | t.Fatalf("empty sessionPath unexpectedly emitted a warning: %v", sink.texts()) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // TestSetActiveSessionPath_AcceptsValidInput is a regression guard: the |
| 195 | // validator must not break legitimate callers that pass typical transcript |
| 196 | // paths produced by store.SessionTranscriptPath or filepath.Join(t.TempDir(), "..."). |
| 197 | func TestSetActiveSessionPath_AcceptsValidInput(t *testing.T) { |
| 198 | sink := &captureSink{} |
| 199 | m := NewManager(sink) |
| 200 | defer m.Close() |
| 201 | |
| 202 | sessionPath := filepath.Join(t.TempDir(), "abc.jsonl") |
| 203 | m.SetActiveSessionPath("session-a", sessionPath) |
| 204 | |
| 205 | m.mu.Lock() |
| 206 | cached, hasCached := m.artifactDirs["session-a"] |
| 207 | m.mu.Unlock() |
| 208 | if !hasCached || cached == "" { |
| 209 | t.Fatal("artifactDirs missing the entry for the accepted sessionPath") |
| 210 | } |
| 211 | want := store.SessionJobsDir(sessionPath) |
| 212 | if cached != want { |
| 213 | t.Fatalf("cached dir = %q, want %q", cached, want) |
| 214 | } |
| 215 | if sink.hasText("Ignoring SetActiveSessionPath with invalid session path") { |
| 216 | t.Fatalf("unexpected warning emission for valid input: %v", sink.texts()) |
| 217 | } |
| 218 | |
| 219 | // Follow-up StartForSession in the bound session produces an artifact |
| 220 | // next to the transcript, demonstrating the cache path is intact. |
| 221 | ran := false |
| 222 | done := make(chan struct{}) |
| 223 | j := m.StartForSession("session-a", "bash", "round trip", func(_ context.Context, _ io.Writer) (string, error) { |
| 224 | ran = true |
| 225 | close(done) |
| 226 | return "ok", nil |
| 227 | }) |
| 228 | j.mu.Lock() |
| 229 | artifactErr := j.artifactErr |
| 230 | artifactPath := j.artifactPath |
| 231 | j.mu.Unlock() |
| 232 | if artifactErr != "" { |
| 233 | t.Fatalf("artifactErr = %q, want empty", artifactErr) |
| 234 | } |
| 235 | if !strings.HasPrefix(artifactPath, want) { |
| 236 | t.Fatalf("artifactPath = %q, want prefix %q", artifactPath, want) |
| 237 | } |
| 238 | <-done |
| 239 | if !ran { |
| 240 | t.Fatal("run callback never executed for the bound session") |
| 241 | } |
| 242 | } |
| 243 |