Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions cmd/dispatch/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ var (
openGetLastSessionFn = defaultOpenGetLastSession
openLaunchFn = defaultOpenLaunch
openResumeCmdFn = platform.BuildResumeCommandString
openListSessionsFn = defaultStatsListSessions
openListSessionsFn = defaultOpenListScopedSessions
openDetectGitFn = detectGitContext

// openStdin is the reader used by --stdin batch resume. It is a package
// variable so tests can feed it a fixed list of IDs.
Expand Down Expand Up @@ -314,7 +315,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool,
Folder: folder,
}
if current {
detectedRepo, detectedBranch, cErr := detectGitContext()
detectedRepo, detectedBranch, cErr := openDetectGitFn()
if cErr != nil {
return "", "", false, false, false, launchOverrides{}, nil, fmt.Errorf("--current: %w", cErr)
}
Expand All @@ -324,6 +325,13 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool,
if filter.Branch == "" {
filter.Branch = detectedBranch
}
// Guard against an empty scope: without a detected repo/branch (no
// origin remote, detached HEAD) the filter would match every
// session and resume an unrelated one.
if filter.Repository == "" && filter.Branch == "" && filter.Folder == "" {
return "", "", false, false, false, launchOverrides{}, nil,
errors.New("--current: could not detect a repository or branch from the current directory")
}
}
scopeFilter = &filter
default:
Expand Down Expand Up @@ -424,6 +432,24 @@ func defaultOpenGetLastSession() (*data.Session, error) {
return &sessions[0], nil
}

// defaultOpenListScopedSessions lists sessions matching a scope filter, ordered
// most-recently-active first (the same ordering --last uses), so the scoped
// resume path can pick sessions[0] as the most recent match.
func defaultOpenListScopedSessions(filter data.FilterOptions) ([]data.Session, error) {
store, err := data.Open()
if err != nil {
return nil, fmt.Errorf("opening session store: %w", err)
}
defer store.Close() //nolint:errcheck // read-only, best-effort close

sortOpts := data.SortOptions{Field: data.SortByUpdated, Order: data.Descending}
sessions, err := store.ListSessions(context.Background(), filter, sortOpts, statsQueryLimit)
if err != nil {
return nil, fmt.Errorf("listing sessions: %w", err)
}
return sessions, nil
}

// defaultOpenLaunch resumes the session using the resolved launch mode. For
// in-place mode it runs the Copilot CLI in the current terminal and waits for
// it to exit. For tab, window, and pane modes it delegates to the platform
Expand Down
55 changes: 55 additions & 0 deletions cmd/dispatch/open_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,61 @@ func TestRunOpen_ScopedResume(t *testing.T) {
}
}

// TestRunOpen_ScopedResumePicksMostRecent verifies the scoped resume selects the
// first session returned by the lister — which lists most-recently-active first,
// so the newest matching session is resumed (regression: it used to take the
// oldest via a created-ascending sort).
func TestRunOpen_ScopedResumePicksMostRecent(t *testing.T) {
cfg := config.Default()
newest := data.Session{ID: "newest", Repository: "x/y"}
older := data.Session{ID: "older", Repository: "x/y"}
capture := withOpenStubs(t, cfg, &newest, nil)

origList := openListSessionsFn
// Most-recently-active first, as defaultOpenListScopedSessions returns.
openListSessionsFn = func(data.FilterOptions) ([]data.Session, error) {
return []data.Session{newest, older}, nil
}
t.Cleanup(func() { openListSessionsFn = origList })

if err := runOpen(io.Discard, []string{"open", "--repo", "x/y"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capture.session == nil || capture.session.ID != "newest" {
t.Errorf("resumed %+v, want the most recent session 'newest'", capture.session)
}
}

// TestRunOpen_CurrentNoDetection verifies --current errors instead of resuming
// an unrelated session when no repo/branch can be detected (no origin remote /
// detached HEAD), which would otherwise leave an all-matching empty filter.
func TestRunOpen_CurrentNoDetection(t *testing.T) {
withOpenStubs(t, config.Default(), nil, nil)

origDetect := openDetectGitFn
openDetectGitFn = func() (string, string, error) { return "", "", nil }
t.Cleanup(func() { openDetectGitFn = origDetect })

origList := openListSessionsFn
listCalled := false
openListSessionsFn = func(data.FilterOptions) ([]data.Session, error) {
listCalled = true
return nil, nil
}
t.Cleanup(func() { openListSessionsFn = origList })

err := runOpen(io.Discard, []string{"open", "--current"})
if err == nil {
t.Fatal("expected an error when nothing is detected, got nil")
}
if !strings.Contains(err.Error(), "could not detect") {
t.Errorf("error = %q, want a 'could not detect' message", err.Error())
}
if listCalled {
t.Error("session lister should not run when detection is empty")
}
}

func TestRunOpen_ScopedResumeNoMatch(t *testing.T) {
withOpenStubs(t, config.Default(), nil, nil)

Expand Down
39 changes: 32 additions & 7 deletions cmd/dispatch/prune.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -11,9 +12,26 @@ import (
"github.com/jongio/dispatch/internal/data"
)

// pruneListSessionsFn loads sessions for the prune command. It is a package
// variable so tests can substitute a fixed set of sessions.
var pruneListSessionsFn = defaultStatsListSessions
// pruneAllSessionIDsFn returns the IDs of every session in the store, including
// empty ones. It is a package variable so tests can substitute a fixed set. It
// must NOT use the filtered session list (which drops empty sessions), or prune
// would delete config metadata for real-but-empty sessions.
var pruneAllSessionIDsFn = defaultPruneAllSessionIDs

// defaultPruneAllSessionIDs loads all session IDs from the default store.
func defaultPruneAllSessionIDs() ([]string, error) {
store, err := data.Open()
if err != nil {
return nil, fmt.Errorf("opening session store: %w", err)
}
defer store.Close() //nolint:errcheck // read-only, best-effort close

ids, err := store.AllSessionIDs(context.Background())
if err != nil {
return nil, fmt.Errorf("listing session IDs: %w", err)
}
return ids, nil
}

// pruneCategory describes stale entries found in one config section.
type pruneCategory struct {
Expand Down Expand Up @@ -53,19 +71,26 @@ func runPrune(w io.Writer, args []string) error {
return fmt.Errorf("loading config: %w", err)
}

sessions, err := pruneListSessionsFn(data.FilterOptions{})
ids, err := pruneAllSessionIDsFn()
if err != nil {
return err
}

liveIDs := make(map[string]bool, len(sessions))
for _, s := range sessions {
liveIDs[s.ID] = true
liveIDs := make(map[string]bool, len(ids))
for _, id := range ids {
liveIDs[id] = true
}

report := buildPruneReport(cfg, liveIDs)
report.Applied = opts.apply

// Safety guard: an empty store would classify every config entry as stale
// and wipe all of it. That is almost always a misconfiguration (wrong DB
// path) rather than genuine intent, so refuse to apply.
if opts.apply && len(liveIDs) == 0 && report.TotalStale > 0 {
return fmt.Errorf("refusing to prune: the session store has no sessions, so applying would remove all %d config entries; if this is intentional, edit the config file directly", report.TotalStale)
}

if opts.apply && report.TotalStale > 0 {
applyPrune(cfg, liveIDs)
if err := configSaveFn(cfg); err != nil {
Expand Down
35 changes: 30 additions & 5 deletions cmd/dispatch/prune_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ func withPruneSeams(t *testing.T, cfg *config.Config, sessions []data.Session) {
configSaveFn = func(c *config.Config) error { return nil }
t.Cleanup(func() { configLoadFn = prevLoad; configSaveFn = prevSave })

prevList := pruneListSessionsFn
pruneListSessionsFn = func(data.FilterOptions) ([]data.Session, error) {
return sessions, nil
}
t.Cleanup(func() { pruneListSessionsFn = prevList })
prevList := pruneAllSessionIDsFn
pruneAllSessionIDsFn = func() ([]string, error) {
ids := make([]string, len(sessions))
for i, s := range sessions {
ids[i] = s.ID
}
return ids, nil
}
t.Cleanup(func() { pruneAllSessionIDsFn = prevList })
}

func makePruneConfig() *config.Config {
Expand Down Expand Up @@ -93,6 +97,27 @@ func TestRunPrune_Apply(t *testing.T) {
}
}

// TestRunPrune_EmptyStoreGuard verifies that --apply refuses to wipe all config
// entries when the store reports zero sessions (a likely misconfiguration),
// rather than silently deleting everything.
func TestRunPrune_EmptyStoreGuard(t *testing.T) {
cfg := makePruneConfig()
withPruneSeams(t, cfg, nil) // empty store: no session IDs

var buf bytes.Buffer
err := runPrune(&buf, []string{"prune", "--apply"})
if err == nil {
t.Fatal("expected a guard error when applying against an empty store")
}
if !bytes.Contains([]byte(err.Error()), []byte("refusing to prune")) {
t.Errorf("error = %q, want a 'refusing to prune' guard message", err.Error())
}
// Nothing should have been deleted.
if _, ok := cfg.SessionAliases["gone-1"]; !ok {
t.Error("guard must not delete any config entries")
}
}

func TestRunPrune_JSON(t *testing.T) {
cfg := makePruneConfig()
sessions := []data.Session{{ID: "live-1"}}
Expand Down
28 changes: 24 additions & 4 deletions cmd/dispatch/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,6 @@ func writeStatsJSON(w io.Writer, report statsReport) error {
// writeStatsCSV prints the report as RFC 4180 CSV rows.
func writeStatsCSV(w io.Writer, report statsReport) error {
cw := csv.NewWriter(w)
defer cw.Flush()

if err := cw.Write([]string{"section", "label", "count"}); err != nil {
return err
Expand All @@ -367,17 +366,17 @@ func writeStatsCSV(w io.Writer, report statsReport) error {
}

for _, e := range report.ByRepository {
if err := cw.Write([]string{"repository", e.Label, strconv.Itoa(e.Count)}); err != nil {
if err := cw.Write([]string{"repository", csvSafe(e.Label), strconv.Itoa(e.Count)}); err != nil {
return err
}
}
for _, e := range report.ByBranch {
if err := cw.Write([]string{"branch", e.Label, strconv.Itoa(e.Count)}); err != nil {
if err := cw.Write([]string{"branch", csvSafe(e.Label), strconv.Itoa(e.Count)}); err != nil {
return err
}
}
for _, e := range report.ByHostType {
if err := cw.Write([]string{"host_type", e.Label, strconv.Itoa(e.Count)}); err != nil {
if err := cw.Write([]string{"host_type", csvSafe(e.Label), strconv.Itoa(e.Count)}); err != nil {
return err
}
}
Expand All @@ -390,9 +389,30 @@ func writeStatsCSV(w io.Writer, report statsReport) error {
}
}

cw.Flush()
if err := cw.Error(); err != nil {
return err
}
return nil
}

// csvSafe neutralizes spreadsheet formula injection (CWE-1236). encoding/csv
// only applies RFC 4180 quoting, not formula-trigger escaping, so a field
// beginning with =, +, -, @, tab, or CR would be evaluated as a formula when
// the CSV is opened in Excel/Sheets/LibreOffice. Prefix such values with a
// single quote so they render as literal text.
func csvSafe(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + s
default:
return s
}
}

// writeStatsText prints the report in a plain, human-readable layout.
func writeStatsText(w io.Writer, report statsReport) {
fmt.Fprintln(w, "Dispatch stats")
Expand Down
42 changes: 42 additions & 0 deletions cmd/dispatch/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,45 @@ func TestRunStatsCSV(t *testing.T) {
t.Errorf("CSV missing repo breakdown, got:\n%s", out)
}
}

// TestRunStatsCSV_FormulaInjection verifies a repo/branch label starting with a
// spreadsheet formula trigger is neutralized (prefixed with ') so it can't
// execute as a formula when opened in Excel/Sheets (CWE-1236).
func TestRunStatsCSV_FormulaInjection(t *testing.T) {
withStatsList(t, func(data.FilterOptions) ([]data.Session, error) {
return []data.Session{
{ID: "s1", Repository: "=cmd|' /c calc'!A1", Branch: "main"},
{ID: "s2", Repository: "=cmd|' /c calc'!A1", Branch: "main"},
}, nil
})

var buf bytes.Buffer
if err := runStats(&buf, []string{"stats", "--csv"}); err != nil {
t.Fatalf("runStats --csv: %v", err)
}
out := buf.String()
if strings.Contains(out, "\n=cmd") || strings.Contains(out, ",=cmd") {
t.Errorf("CSV emitted an unescaped formula label, got:\n%s", out)
}
if !strings.Contains(out, "'=cmd") {
t.Errorf("CSV should prefix a formula-triggering label with a quote, got:\n%s", out)
}
}

func TestCsvSafe(t *testing.T) {
cases := map[string]string{
"": "",
"jongio/repo": "jongio/repo",
"main": "main",
"=1+1": "'=1+1",
"+cmd": "'+cmd",
"-2": "'-2",
"@ref": "'@ref",
"\ttab": "'\ttab",
}
for in, want := range cases {
if got := csvSafe(in); got != want {
t.Errorf("csvSafe(%q) = %q, want %q", in, got, want)
}
}
}
Loading