Skip to content

Commit 06fd960

Browse files
authored
fix: owner-only session exports and surfaced token-store persist errors (#50)
* fix(tui): write session exports owner-only (0600) Transcripts can carry sensitive tool output; the exported artifact now matches the tokens/settings store standard instead of being world-readable. * fix(tokens): surface persist errors and remove orphaned tmp persist() now returns an error instead of silently swallowing every failure; Set/Delete report it on stderr while keeping best-effort semantics. A failed rename no longer leaves the staged .tmp behind.
1 parent 7c0a0dc commit 06fd960

4 files changed

Lines changed: 109 additions & 10 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package tokens
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// TestPersistSurfacesErrors pins that persist reports failures instead of
10+
// silently dropping them: a store that fails to save must be observable,
11+
// otherwise session resume breaks with no diagnostic.
12+
func TestPersistSurfacesErrors(t *testing.T) {
13+
dir := t.TempDir()
14+
// A regular file where the store directory should be makes MkdirAll fail.
15+
blocker := filepath.Join(dir, "blocker")
16+
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
17+
t.Fatal(err)
18+
}
19+
err := persist(filepath.Join(blocker, "sessions.json"), map[string]string{"id": "tok"})
20+
if err == nil {
21+
t.Error("persist against an unwritable path should report an error")
22+
}
23+
}
24+
25+
// TestPersistNoTmpLeftOnRenameFailure pins the tmp cleanup: when the final
26+
// rename fails, the staged .tmp file must not be left behind.
27+
func TestPersistNoTmpLeftOnRenameFailure(t *testing.T) {
28+
dir := t.TempDir()
29+
store := filepath.Join(dir, "sessions.json")
30+
// A non-empty directory at the store path makes the final rename fail.
31+
if err := os.Mkdir(store, 0o700); err != nil {
32+
t.Fatal(err)
33+
}
34+
if err := os.WriteFile(filepath.Join(store, "occupied"), []byte("x"), 0o600); err != nil {
35+
t.Fatal(err)
36+
}
37+
if err := persist(store, map[string]string{"id": "tok"}); err == nil {
38+
t.Error("persist onto a directory path should report an error")
39+
}
40+
if _, err := os.Stat(store + ".tmp"); !os.IsNotExist(err) {
41+
t.Errorf("failed persist left the .tmp file behind: %v", err)
42+
}
43+
}

‎internal/tokens/tokens.go‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
// auth_token) and requires it on the cancel/detail/delete endpoints. The Web
66
// UI keeps these in localStorage; bodek keeps them in ~/.bodek/sessions.json.
77
// Persistence is best-effort — a Store with no writable path still works as an
8-
// in-memory cache for the current run.
8+
// in-memory cache for the current run; failures are reported on stderr.
99
package tokens
1010

1111
import (
1212
"encoding/json"
13+
"fmt"
1314
"os"
1415
"path/filepath"
1516
"sync"
@@ -65,7 +66,9 @@ func (s *Store) Set(id, token string) {
6566
}
6667
path := s.path
6768
s.mu.Unlock()
68-
persist(path, snapshot)
69+
if err := persist(path, snapshot); err != nil {
70+
warnPersist(err)
71+
}
6972
}
7073

7174
// Delete removes a session's token and persists the store (best-effort).
@@ -85,23 +88,38 @@ func (s *Store) Delete(id string) {
8588
}
8689
path := s.path
8790
s.mu.Unlock()
88-
persist(path, snapshot)
91+
if err := persist(path, snapshot); err != nil {
92+
warnPersist(err)
93+
}
8994
}
9095

91-
func persist(path string, m map[string]string) {
96+
// persist atomically writes the store (staged .tmp + rename) so a crash
97+
// mid-write never corrupts the previous snapshot.
98+
func persist(path string, m map[string]string) error {
9299
if path == "" {
93-
return
100+
return nil
94101
}
95102
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
96-
return
103+
return fmt.Errorf("create store dir: %w", err)
97104
}
98105
data, err := json.MarshalIndent(m, "", " ")
99106
if err != nil {
100-
return
107+
return fmt.Errorf("encode store: %w", err)
101108
}
102109
tmp := path + ".tmp"
103110
if err := os.WriteFile(tmp, data, 0o600); err != nil {
104-
return
111+
return fmt.Errorf("write store: %w", err)
112+
}
113+
if err := os.Rename(tmp, path); err != nil {
114+
_ = os.Remove(tmp) // don't leave the staged copy behind
115+
return fmt.Errorf("replace store: %w", err)
105116
}
106-
_ = os.Rename(tmp, path)
117+
return nil
118+
}
119+
120+
// warnPersist reports a failed best-effort save without aborting the
121+
// operation: the store stays a working in-memory cache, but a silent failure
122+
// would break session resume with no diagnostic.
123+
func warnPersist(err error) {
124+
fmt.Fprintf(os.Stderr, "bodek: warning: session token store not saved: %v\n", err)
107125
}

‎internal/tui/export_test.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package tui
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/BackendStack21/bodek/internal/client"
8+
)
9+
10+
// TestExportWritesOwnerOnlyFile pins the export file mode: transcripts can
11+
// carry sensitive tool output, so the artifact must be readable by its owner
12+
// only (0600), matching the tokens/settings store standard.
13+
func TestExportWritesOwnerOnlyFile(t *testing.T) {
14+
m := wired(t)
15+
t.Chdir(t.TempDir()) // exports land in the process CWD
16+
17+
m.panel = panelSessions
18+
m.sessions = []client.Session{{ID: "s1"}}
19+
m.panelSel = 0
20+
21+
msg := exec(m.exportSelected("md"))
22+
em, ok := msg.(sessionExportedMsg)
23+
if !ok {
24+
t.Fatalf("export cmd yielded %#v, want sessionExportedMsg", msg)
25+
}
26+
if em.err != nil {
27+
t.Fatalf("export failed: %v", em.err)
28+
}
29+
info, err := os.Stat(em.path)
30+
if err != nil {
31+
t.Fatalf("stat exported file: %v", err)
32+
}
33+
if got := info.Mode().Perm(); got != 0o600 {
34+
t.Errorf("export %s mode = %o, want 600", em.path, got)
35+
}
36+
}

‎internal/tui/panels.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,9 @@ func (m *Model) exportSelected(format string) tea.Cmd {
731731
return sessionExportedMsg{id: id, err: err}
732732
}
733733
path := fmt.Sprintf("bodek-%s.%s", id, format)
734-
if err := os.WriteFile(path, data, 0o644); err != nil {
734+
// 0600: transcripts can carry sensitive tool output — owner-only,
735+
// matching the tokens/settings store standard.
736+
if err := os.WriteFile(path, data, 0o600); err != nil {
735737
return sessionExportedMsg{id: id, err: err}
736738
}
737739
return sessionExportedMsg{id: id, path: path}

0 commit comments

Comments
 (0)