返回 DeepSeek-Reasonix
jobs_sessionpath_test.go
根目录 / internal / jobs / jobs_sessionpath_test.go
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
243 lines GO