diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e9b7c5..5290ee3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,32 @@ on: branches: [main] pull_request: branches: [main] + # Weekly run on main: govulncheck can start failing with no code change + # when a new Go vulnerability is published, so don't wait for a push to + # find out. + schedule: + - cron: "17 5 * * 1" + # Allow on-demand runs from the Actions tab or the API. + workflow_dispatch: + +# Least privilege: CI only needs to read the repo. +permissions: + contents: read jobs: build: - runs-on: macos-latest + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + # Actions are pinned to commit SHAs (Dependabot keeps them current); + # a moved or compromised tag can't change what runs here. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod check-latest: true @@ -19,7 +38,9 @@ jobs: run: go build ./... - name: Vet run: go vet ./... + - name: Test + run: go test ./... - name: Govulncheck run: | - go install golang.org/x/vuln/cmd/govulncheck@latest + go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 govulncheck ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 43614f8..cd5dec6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,8 @@ on: schedule: # Re-scan main weekly so new query updates surface dormant issues. - cron: "37 6 * * 1" + # Allow on-demand scans from the Actions tab or the API. + workflow_dispatch: jobs: analyze: @@ -20,15 +22,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@1190a975f95ce23525efb6a3fc21ea29567c1b52 # v3.38.2 with: languages: go queries: security-and-quality @@ -37,6 +41,6 @@ jobs: run: go build ./... - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@1190a975f95ce23525efb6a3fc21ea29567c1b52 # v3.38.2 with: category: "/language:go" diff --git a/README.md b/README.md index 3d0e5f9..55756a8 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![CI](https://github.com/abinashstack/indmoney-watch/actions/workflows/ci.yml/badge.svg)](https://github.com/abinashstack/indmoney-watch/actions/workflows/ci.yml) [![CodeQL](https://github.com/abinashstack/indmoney-watch/actions/workflows/codeql.yml/badge.svg)](https://github.com/abinashstack/indmoney-watch/actions/workflows/codeql.yml) -[![Go](https://img.shields.io/badge/Go-1.22%2B-00ADD8?logo=go&logoColor=white)](https://go.dev) -[![Platform](https://img.shields.io/badge/Platform-macOS-000?logo=apple&logoColor=white)](https://www.apple.com/macos) +[![Go](https://img.shields.io/badge/Go-1.26%2B-00ADD8?logo=go&logoColor=white)](https://go.dev) +[![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-000)](#-quickstart) [![MCP](https://img.shields.io/badge/Protocol-MCP-7c3aed)](https://modelcontextprotocol.io) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Last commit](https://img.shields.io/github/last-commit/abinashstack/indmoney-watch?color=informational)](https://github.com/abinashstack/indmoney-watch/commits/main) @@ -41,12 +41,13 @@ | 🪙 **SIP alerts** | Failed installments (sticky transition), upcoming due dates, optional success confirmations | | 💳 **Credit-card warnings** | Configurable lead time before due dates | | 🍎 **macOS menu bar** | [SwiftBar](https://github.com/swiftbar/SwiftBar) plugin: live totals, watchlist hover-submenus, one-click actions | -| ⏰ **Background daemon** | `launchd` agent polling Mon–Fri 09:00–16:00 IST, every 10 min by default | -| 🔐 **OAuth 2.1 + PKCE + DCR** | Tokens in macOS Keychain — never on disk | +| ⏰ **Background daemon** | `launchd` agent (macOS) or Task Scheduler task (Windows) polling Mon–Fri 09:00–16:00 IST, every 10 min by default | +| 🪟 **Windows support** | Toast notifications, Task Scheduler poller, DPAPI-encrypted token storage (menu bar is macOS-only) | +| 🔐 **OAuth 2.1 + PKCE + DCR** | Tokens in macOS Keychain, or DPAPI-encrypted on Windows — never in plaintext | ## 🚀 Quickstart -> Requires **Go 1.22+** and **macOS**. +> Requires **Go 1.26+** and **macOS** or **Windows 10 (1809+) / 11**. ```bash git clone https://github.com/abinashstack/indmoney-watch @@ -57,6 +58,32 @@ indw login `indw login` opens a browser, completes OAuth against `mcp.indmoney.com`, and stashes tokens in your Keychain (service: `indmoney-watch`). +
+🪟 Windows + +In PowerShell: + +```powershell +git clone https://github.com/abinashstack/indmoney-watch +cd indmoney-watch +New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\indw" | Out-Null +go build -o "$env:LOCALAPPDATA\indw\indw.exe" ./cmd/indw +& "$env:LOCALAPPDATA\indw\indw.exe" login +& "$env:LOCALAPPDATA\indw\indw.exe" start # register the background poller +``` + +(Add `%LOCALAPPDATA%\indw` to your user `PATH` to just type `indw`.) + +What's different on Windows: + +- **Tokens** are encrypted with DPAPI (bound to your Windows account) in `%AppData%\indmoney-watch\tokens.dpapi`. +- **Alerts** are Windows toast notifications. +- **`indw start`** registers a Task Scheduler task named `indmoney-watch` that runs hidden while you're logged on, including on battery. `indw stop` removes it. +- **Config, state and logs** live in `%AppData%\indmoney-watch\` (`indw paths` shows them). +- **No menu bar app** — SwiftBar is macOS-only. Use `indw status`, `indw watchlist` and `indw sips`. + +
+
🍎 Add the menu-bar app (SwiftBar) @@ -73,7 +100,7 @@ The plugin refreshes every 10 minutes by default (configurable via `menubar.refr ⏰ Run the background alert daemon ```bash -indw start # installs launchd agent — polls Mon–Fri 09:00–16:00 IST every 10 min +indw start # installs launchd agent (Task Scheduler task on Windows) — polls Mon–Fri 09:00–16:00 IST every 10 min indw stop # uninstalls indw logs -f # tail the agent log ``` @@ -175,21 +202,22 @@ menubar: | `get_indian_stocks_details` / `get_us_stocks_details` | Live LTP + day change (capped at 10 ids/call, transparently chunked) | | `indian_stocks_sips` / `mf_sips` | SIP status, amounts, next execution | -Polling state lives in `~/.config/indmoney-watch/state.json` (last-seen percentages and alert-firing timestamps for debouncing). OAuth tokens live in the macOS Keychain. +Polling state lives in `~/.config/indmoney-watch/state.json` (`%AppData%\indmoney-watch\state.json` on Windows): last-seen percentages and alert-firing timestamps for debouncing. OAuth tokens live in the macOS Keychain, or in a DPAPI-encrypted file on Windows. ## 🤝 Contributing PRs welcome. Some directions if you're looking for ideas: -- 🌐 **Cross-platform notifications** — currently uses `osascript` (macOS only). A Linux/Windows backend would open this up beyond Apple-land. +- 🐧 **Linux support** — macOS and Windows are covered; Linux needs a notification backend (`notify-send`), a secret store (libsecret) and a systemd timer. +- 🪟 **Windows tray icon** — the SwiftBar menu is macOS-only; a system-tray equivalent would round out Windows support. - 🔧 **More tools** — `lookup_ind_keys` for friendly target naming, 52-week high/low alerts, OHLC sparkline rendering in the menu bar. - ♻️ **Refactor `launchd` setup** — IST scheduling assumes the host TZ; a sleep-loop daemon mode would be more portable. -- ✅ **Tests** — currently zero. The MCP layer is a good target. +- ✅ **Tests** — `go test ./...` runs in CI on macOS and Windows; more coverage of the alert engine is welcome. When opening a PR: 1. `go build ./...` should succeed cleanly. -2. `go vet ./...` should pass. +2. `go vet ./...` and `go test ./...` should pass. 3. If you change MCP tool calls, mention the tool name + payload in the PR — INDmoney's contract isn't documented anywhere public, so reverse-engineered notes help. ## ⚠️ Caveats diff --git a/SECURITY.md b/SECURITY.md index d8b6112..e69a62b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,11 +35,17 @@ Out of scope: - The INDmoney MCP server itself (report to INDmoney) - Vulnerabilities in dependencies — we run `govulncheck` in CI and Dependabot for security updates; if you find something they miss, we still want to know -- Issues that require local root or physical access (the threat model assumes a single-user macOS account) +- Issues that require local root or physical access (the threat model assumes a single-user macOS or Windows account) ## Hardening notes for self-deployers -- OAuth tokens live in macOS Keychain, not in files. The fallback case is also covered in the audit notes. +- OAuth tokens live in macOS Keychain, not in files. **Limitation:** the item is created via `/usr/bin/security`, so its access list trusts that tool, and any process running as your user can read it without a prompt (`security find-generic-password -s indmoney-watch -w`). Keychain storage protects against other users and offline disk access, not against malware running in your own account. If that matters to you, open Keychain Access and restrict the `indmoney-watch` item's access control, or revoke the session from INDmoney after use. +- On Windows, tokens are encrypted with DPAPI (`CryptProtectData`, bound to your Windows account, plus an application-specific entropy value) and stored in `%AppData%\indmoney-watch\tokens.dpapi`. As with the macOS Keychain, this protects against other users and offline disk access, not against malware running as you. +- Windows toast notifications are rendered by Windows PowerShell with text passed through environment variables and XML-escaped — never spliced into the script — and the script itself is passed via `-EncodedCommand`. System binaries (`powershell.exe`, `schtasks.exe`, `conhost.exe`, `rundll32.exe`) are run from `%SystemRoot%\System32` by absolute path. +- Token refreshes are serialised across processes (launchd poller, SwiftBar plugin, CLI) with a lock file in `~/.config/indmoney-watch/`, so concurrent refreshes can't burn a rotated refresh token. +- Errors from the OAuth endpoints only ever report the OAuth error code, never the response body, so tokens and client secrets don't end up in `agent.log`. `agent.log` does contain alert text (holdings, P&L, card dues); it lives in the owner-only (`0700`) config directory. +- Text from INDmoney (names, asset types, error bodies) is stripped of `|`, newlines and control characters before it reaches the SwiftBar plugin output or your terminal, so it can't add clickable `bash=` actions or emit terminal escape sequences. +- System tools (`security`, `osascript`, `launchctl`, `open`, `defaults`, `tail`) are invoked by absolute path, never looked up via `$PATH`. - The SwiftBar plugin script is installed with `0700` (owner-only) to defend against local plugin-swap attacks. If you're upgrading from an older `indw`, re-run `indw menubar install` to apply the tighter perms. - macOS notifications are rendered via `osascript` with strings passed through environment variables, not concatenated into the AppleScript body — INDmoney-supplied names cannot inject AppleScript. diff --git a/cmd/indw/logs.go b/cmd/indw/logs.go new file mode 100644 index 0000000..203fbf3 --- /dev/null +++ b/cmd/indw/logs.go @@ -0,0 +1,144 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/abinashstack/indmoney-watch/internal/config" +) + +const ( + logTailLines = 200 + // maxLogSize triggers a single-generation rotation (agent.log → agent.log.1) + // when the poller writes the log itself, so it can't grow without bound. + maxLogSize = 5 << 20 +) + +func agentLogPath() (string, error) { + d, err := config.Dir() + if err != nil { + return "", err + } + return filepath.Join(d, "agent.log"), nil +} + +// redirectToAgentLog points this process's stdout and stderr at agent.log +// (append), rotating it first if it has grown past maxLogSize. +func redirectToAgentLog() error { + p, err := agentLogPath() + if err != nil { + return err + } + if fi, err := os.Stat(p); err == nil && fi.Size() > maxLogSize { + _ = os.Rename(p, p+".1") + } + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + fmt.Fprintf(f, "--- run-once %s\n", time.Now().Format(time.RFC3339)) + os.Stdout = f + os.Stderr = f + return nil +} + +// cmdLogs prints the last lines of agent.log and, with -f, follows it. It's +// implemented in Go rather than shelling out to tail(1) so it behaves the +// same on macOS and Windows. +func cmdLogs(args []string) error { + p, err := agentLogPath() + if err != nil { + return err + } + follow := false + for _, a := range args { + if a == "-f" || a == "--follow" { + follow = true + } + } + f, err := os.Open(p) + if errors.Is(err, os.ErrNotExist) { + fmt.Println("(no log yet — agent hasn't run; use `indw start` to install it, or `indw run-once` to test)") + return nil + } + if err != nil { + return err + } + defer f.Close() + + off, err := printTail(os.Stdout, f, logTailLines) + if err != nil || !follow { + return err + } + return followFile(os.Stdout, p, off, time.Second, nil) +} + +// printTail writes the last n lines of f to w and returns f's size, i.e. the +// offset to follow from. It reads at most the final 1 MiB. +func printTail(w io.Writer, f *os.File, n int) (int64, error) { + fi, err := f.Stat() + if err != nil { + return 0, err + } + size := fi.Size() + start := size - 1<<20 + if start < 0 { + start = 0 + } + buf := make([]byte, size-start) + if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF { + return 0, err + } + buf = bytes.TrimRight(buf, "\n") + lines := bytes.Split(buf, []byte("\n")) + if start > 0 && len(lines) > 1 { + lines = lines[1:] // first line is probably partial + } + if len(lines) > n { + lines = lines[len(lines)-n:] + } + bw := bufio.NewWriter(w) + for _, l := range lines { + if len(l) == 0 && len(lines) == 1 { + break + } + bw.Write(l) + bw.WriteByte('\n') + } + return size, bw.Flush() +} + +// followFile polls path and copies anything appended after off to w. If the +// file shrinks or is replaced (rotation), it starts again from the top. It +// returns when stop is closed (nil stop: runs until the process exits). +func followFile(w io.Writer, path string, off int64, every time.Duration, stop <-chan struct{}) error { + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-stop: + return nil + case <-t.C: + } + f, err := os.Open(path) + if err != nil { + continue // mid-rotation + } + if fi, err := f.Stat(); err == nil { + if fi.Size() < off { + off = 0 + } + if fi.Size() > off { + n, _ := io.Copy(w, io.NewSectionReader(f, off, fi.Size()-off)) + off += n + } + } + f.Close() + } +} diff --git a/cmd/indw/logs_test.go b/cmd/indw/logs_test.go new file mode 100644 index 0000000..c269424 --- /dev/null +++ b/cmd/indw/logs_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPrintTail(t *testing.T) { + p := filepath.Join(t.TempDir(), "agent.log") + var all strings.Builder + for i := 1; i <= 300; i++ { + fmt.Fprintf(&all, "line %d\n", i) + } + if err := os.WriteFile(p, []byte(all.String()), 0o600); err != nil { + t.Fatal(err) + } + f, _ := os.Open(p) + defer f.Close() + var out bytes.Buffer + off, err := printTail(&out, f, 200) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 200 || lines[0] != "line 101" || lines[199] != "line 300" { + t.Fatalf("got %d lines, first=%q last=%q", len(lines), lines[0], lines[len(lines)-1]) + } + if off != int64(all.Len()) { + t.Fatalf("offset %d, want %d", off, all.Len()) + } + + // Empty file prints nothing. + empty := filepath.Join(t.TempDir(), "empty.log") + _ = os.WriteFile(empty, nil, 0o600) + ef, _ := os.Open(empty) + defer ef.Close() + out.Reset() + if _, err := printTail(&out, ef, 200); err != nil || out.Len() != 0 { + t.Fatalf("empty file: err=%v out=%q", err, out.String()) + } +} + +type syncBuf struct { + ch chan string +} + +func (s *syncBuf) Write(p []byte) (int, error) { s.ch <- string(p); return len(p), nil } + +func TestFollowFileAppendsAndRotation(t *testing.T) { + p := filepath.Join(t.TempDir(), "agent.log") + _ = os.WriteFile(p, []byte("old\n"), 0o600) + w := &syncBuf{ch: make(chan string, 10)} + stop := make(chan struct{}) + done := make(chan error) + go func() { done <- followFile(w, p, 4, 10*time.Millisecond, stop) }() + + expect := func(want string) { + t.Helper() + select { + case got := <-w.ch: + if got != want { + t.Fatalf("followed %q, want %q", got, want) + } + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q", want) + } + } + + f, _ := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0) + f.WriteString("new 1\n") + f.Close() + expect("new 1\n") + + // Rotation: file replaced by a shorter one — follow restarts from 0. + _ = os.WriteFile(p, []byte("r\n"), 0o600) + expect("r\n") + + close(stop) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestRedirectToAgentLogRotates(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("AppData", home) + p, err := agentLogPath() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, bytes.Repeat([]byte("x"), maxLogSize+1), 0o600); err != nil { + t.Fatal(err) + } + origOut, origErr := os.Stdout, os.Stderr + t.Cleanup(func() { os.Stdout, os.Stderr = origOut, origErr }) + if err := redirectToAgentLog(); err != nil { + t.Fatal(err) + } + fmt.Println("hello") + os.Stdout.Close() + os.Stdout, os.Stderr = origOut, origErr + + if fi, err := os.Stat(p + ".1"); err != nil || fi.Size() != maxLogSize+1 { + t.Fatalf("rotated file: %v", err) + } + b, _ := os.ReadFile(p) + if !strings.Contains(string(b), "--- run-once") || !strings.HasSuffix(string(b), "hello\n") { + t.Fatalf("new log = %q", b) + } +} diff --git a/cmd/indw/main.go b/cmd/indw/main.go index ffea273..12b3f25 100644 --- a/cmd/indw/main.go +++ b/cmd/indw/main.go @@ -3,16 +3,16 @@ package main import ( "context" "encoding/json" - "encoding/xml" "errors" "fmt" "os" - "os/exec" "path/filepath" "strconv" "strings" "text/tabwriter" "time" + "unicode" + "unicode/utf8" "github.com/abinashstack/indmoney-watch/internal/alert" "github.com/abinashstack/indmoney-watch/internal/config" @@ -25,8 +25,10 @@ import ( ) const ( - mcpEndpoint = "https://mcp.indmoney.com/mcp" - launchdLabel = "indmoney-watch" + mcpEndpoint = "https://mcp.indmoney.com/mcp" + launchdLabel = "indmoney-watch" + // pollEvery is the launchd agent's cadence during market hours. + pollEvery = 10 * time.Minute ) func usage() { @@ -41,14 +43,15 @@ Usage: indw set-target SYMBOL below PRICE Add price target (below) indw clear-target SYMBOL Remove targets for a symbol indw run-once Single poll (alerts fire if thresholds hit) - indw start Install launchd agent (poll every 10 min, 09:00–16:00 IST) - indw stop Uninstall launchd agent + indw start Install background poller (launchd on macOS, Task Scheduler on Windows; + every 10 min, Mon–Fri 09:00–16:00 IST) + indw stop Uninstall background poller indw config Print config path and contents - indw logs [-f] Show launchd agent log (-f to follow) + indw logs [-f] Show background poller log (-f to follow) indw state Show last snapshot + debounce state indw paths Show all on-disk locations indw menubar Print SwiftBar menu plugin output (renders portfolio in macOS menu bar) - indw menubar install Install as a SwiftBar plugin (prompts to install SwiftBar if missing)`) + indw menubar install Install as a SwiftBar plugin (macOS only)`) fmt.Fprintln(os.Stderr) } @@ -75,7 +78,7 @@ func main() { case "clear-target": must(cmdClearTarget(args)) case "run-once": - must(cmdRunOnce(ctx)) + must(cmdRunOnce(ctx, args)) case "start": must(cmdStart()) case "stop": @@ -118,20 +121,14 @@ func cmdLogin(ctx context.Context) error { } } if !force { - if t, err := store.LoadTokens(); err == nil { - if time.Until(t.ExpiresAt) > 60*time.Second { - fmt.Println("Already logged in. Token valid until", t.ExpiresAt.Local().Format("2006-01-02 15:04 MST")) + // Go through TokenSource so a refresh here takes the same + // cross-process lock as the daemon and menu bar plugin. + if ts, err := store.NewTokenSource(); err == nil { + if _, err := ts.AccessToken(ctx); err == nil { + fmt.Println("Logged in. Token valid until", ts.ExpiresAt().Local().Format("2006-01-02 15:04 MST")) fmt.Println("Use `indw login --force` to re-authenticate.") return nil } - if t.RefreshToken != "" { - if nt, rerr := oauth.Refresh(ctx, t); rerr == nil { - if serr := store.SaveTokens(nt); serr == nil { - fmt.Println("Refreshed existing session. Token valid until", nt.ExpiresAt.Local().Format("2006-01-02 15:04 MST")) - return nil - } - } - } fmt.Println("Existing tokens expired and could not be refreshed; starting full login.") } } @@ -151,7 +148,7 @@ func cmdLogin(ctx context.Context) error { if err := store.SaveTokens(tokens); err != nil { return err } - fmt.Println("Logged in. Tokens stored in macOS Keychain (service: indmoney-watch).") + fmt.Println("Logged in. Tokens stored in", store.Location+".") return nil } @@ -188,13 +185,13 @@ func cmdStatus(ctx context.Context) error { fmt.Fprintln(tw) fmt.Fprintln(tw, "By asset type:") for _, inv := range snap.Investments { - fmt.Fprintf(tw, " %s\t₹%.0f\t%+.2f%%\n", inv.AssetType, inv.CurrentValue, inv.ReturnPercentage) + fmt.Fprintf(tw, " %s\t₹%.0f\t%+.2f%%\n", termSafe(inv.AssetType), inv.CurrentValue, inv.ReturnPercentage) } if len(snap.Liabilities.CreditCards) > 0 { fmt.Fprintln(tw) fmt.Fprintln(tw, "Credit cards:") for _, cc := range snap.Liabilities.CreditCards { - fmt.Fprintf(tw, " %s\t₹%.2f due %s\n", cc.Name, cc.TotalDue, cc.DueDate) + fmt.Fprintf(tw, " %s\t₹%.2f due %s\n", termSafe(cc.Name), cc.TotalDue, termSafe(cc.DueDate)) } } return tw.Flush() @@ -221,8 +218,8 @@ func cmdWatchlist(ctx context.Context) error { if details, err := api.IndianStockDetails(ctx, indKeys); err == nil { for k, ent := range details { fmt.Fprintf(tw, "IND\t%s\t%s\t₹%.2f\t%+.2f\t%s\n", - ent.Basic.Symbol, trunc(ent.Basic.Name, 30), - ent.Stats.LivePrice, ent.Stats.DayChangePct, k) + termSafe(ent.Basic.Symbol), trunc(ent.Basic.Name, 30), + ent.Stats.LivePrice, ent.Stats.DayChangePct, termSafe(k)) } } } @@ -241,8 +238,8 @@ func cmdWatchlist(ctx context.Context) error { if details, err := api.USStockDetails(ctx, tickers); err == nil { for tkr, ent := range details { fmt.Fprintf(tw, "US\t%s\t%s\t$%.2f\t%+.2f\t%s\n", - ent.Basic.Symbol, trunc(ent.Basic.Name, 30), - ent.Stats.LivePrice, ent.Stats.DayChangePct, tkr) + termSafe(ent.Basic.Symbol), trunc(ent.Basic.Name, 30), + ent.Stats.LivePrice, ent.Stats.DayChangePct, termSafe(tkr)) } } else { fmt.Fprintf(tw, "US\t-\tcouldn't fetch details (%v)\t\t\t\n", err) @@ -309,7 +306,7 @@ func printSIPRow(tw *tabwriter.Writer, kind string, s indmoney.SIP) { next = "-" } fmt.Fprintf(tw, "%s\t%s\t₹%.0f\t%s\t%s\t%s\n", - kind, trunc(s.DisplayName(), 32), s.AmountValue(), freq, next, status) + kind, trunc(s.DisplayName(), 32), s.AmountValue(), termSafe(freq), termSafe(next), status) } func cmdSetTarget(args []string) error { @@ -355,12 +352,21 @@ func cmdClearTarget(args []string) error { return config.Save(cfg) } -func cmdRunOnce(ctx context.Context) error { +func cmdRunOnce(ctx context.Context, args []string) error { + // --log appends output to agent.log. launchd redirects stdout/stderr + // itself; Task Scheduler can't, so the Windows task passes this flag. + for _, a := range args { + if a == "--log" { + if err := redirectToAgentLog(); err != nil { + return err + } + } + } + // Cap the entire cycle. The HTTP client has a 30 s per-request timeout but // the engine fires ~10–20 sequential MCP calls, so a degraded upstream can // otherwise drag a single run past the next launchd slot. 2 minutes is - // generous for a healthy poll and leaves headroom before the next 5 min - // fire. + // generous for a healthy poll and leaves headroom before the next fire. ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() @@ -384,8 +390,8 @@ func cmdRunOnce(ctx context.Context) error { return nil } -// notifyIfNeedsLogin surfaces oauth.ErrNeedsLogin to the user via a macOS -// banner — once per 6h cooldown so the daemon doesn't spam every cycle while +// notifyIfNeedsLogin surfaces oauth.ErrNeedsLogin to the user via a desktop +// notification — once per 6h cooldown so the daemon doesn't spam every cycle while // the refresh token is dead. The original error is returned unchanged so the // launchd log still records the underlying cause. func notifyIfNeedsLogin(err error, st *state.State) error { @@ -400,7 +406,7 @@ func notifyIfNeedsLogin(err error, st *state.State) error { } st.LastFired[key] = now _ = state.Save(st) - _ = notify.MacBanner( + _ = notify.Banner( "INDmoney session expired", "Refresh token rejected", "Run `indw login -f` to re-authenticate.", @@ -423,174 +429,31 @@ func cmdConfig() error { return nil } -// ---- launchd ---- - -// plistEscape XML-escapes a string for safe substitution inside a -// … element of the launchd plist we generate. The two values -// we substitute (`exe` from os.Executable, `logFile` under $HOME/.config) are -// trusted in normal use, but a path containing `<` or `&` would corrupt the -// plist, and a maliciously-crafted path could close the tag and -// inject directives like RunAtLoad=true. Defense in depth: cheaper to escape -// every substitution than to reason about whether each input is safe. -func plistEscape(s string) string { - var buf strings.Builder - _ = xml.EscapeText(&buf, []byte(s)) - return buf.String() -} - -func plistPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist"), nil -} - -func cmdStart() error { - exe, err := os.Executable() - if err != nil { - return err - } - exe, _ = filepath.Abs(exe) - logDir, err := config.Dir() - if err != nil { - return err - } - logFile := filepath.Join(logDir, "agent.log") - - plist := fmt.Sprintf(` - - - - Label%s - ProgramArguments - - %s - run-once - - StartCalendarInterval - -%s - - StandardOutPath%s - StandardErrorPath%s - EnvironmentVariables - - PATH/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin - - - -`, launchdLabel, plistEscape(exe), calendarSlots(), plistEscape(logFile), plistEscape(logFile)) - - pp, err := plistPath() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(pp), 0o755); err != nil { - return err - } - if err := os.WriteFile(pp, []byte(plist), 0o644); err != nil { - return err - } - // Bootstrap. - uid := os.Getuid() - target := fmt.Sprintf("gui/%d", uid) - _ = exec.Command("launchctl", "bootout", target, pp).Run() - out, err := exec.Command("launchctl", "bootstrap", target, pp).CombinedOutput() - if err != nil { - return fmt.Errorf("launchctl bootstrap: %w (%s)", err, strings.TrimSpace(string(out))) - } - fmt.Println("Installed launchd agent:", pp) - fmt.Println("Logs:", logFile) - fmt.Println("It will run every 5 minutes between 09:00–16:00 IST, Mon–Fri.") - return nil -} - -func cmdStop() error { - pp, err := plistPath() - if err != nil { - return err - } - uid := os.Getuid() - target := fmt.Sprintf("gui/%d", uid) - _ = exec.Command("launchctl", "bootout", target, pp).Run() - if err := os.Remove(pp); err != nil && !os.IsNotExist(err) { - return err - } - fmt.Println("Removed launchd agent.") - return nil -} - -// calendarSlots returns StartCalendarInterval entries for every 5 minutes -// between 09:00 and 16:00 IST on Mon-Fri. macOS launchd uses local time, -// so we offset for IST (+05:30) → local. The host's local TZ matters; we -// emit IST minute-of-day slots based on the host's current offset. -func calendarSlots() string { - // Convert IST hours to host-local hours. - // 09:00 IST → host local time of 09:00 IST. - istLoc, err := time.LoadLocation("Asia/Kolkata") - if err != nil { - istLoc = time.FixedZone("IST", 5*3600+30*60) - } - var sb strings.Builder - weekdays := []int{1, 2, 3, 4, 5} // Mon-Fri - // 09:00 to 15:55 IST in 5-min steps (last fire 15:55). - for h := 9; h <= 15; h++ { - for m := 0; m < 60; m += 5 { - istT := time.Date(2026, 1, 5, h, m, 0, 0, istLoc) // any Monday - localT := istT.Local() - for _, wd := range weekdays { - sb.WriteString(fmt.Sprintf( - " Weekday%dHour%dMinute%d\n", - wd, localT.Hour(), localT.Minute(), - )) - } - } - } - // One last slot at 16:00 IST. - istT := time.Date(2026, 1, 5, 16, 0, 0, 0, istLoc) - localT := istT.Local() - for _, wd := range weekdays { - sb.WriteString(fmt.Sprintf( - " Weekday%dHour%dMinute%d\n", - wd, localT.Hour(), localT.Minute(), - )) - } - return sb.String() -} - +// trunc shortens s to at most n characters (runes, so multi-byte characters +// are never split) and strips terminal control characters. func trunc(s string, n int) string { - if len(s) <= n { + s = termSafe(s) + if utf8.RuneCountInString(s) <= n { return s } - return s[:n-1] + "…" + r := []rune(s) + return string(r[:n-1]) + "…" } -// ---- inspection commands ---- - -func cmdLogs(args []string) error { - d, err := config.Dir() - if err != nil { - return err - } - logFile := filepath.Join(d, "agent.log") - if _, err := os.Stat(logFile); os.IsNotExist(err) { - fmt.Println("(no log yet — agent hasn't run; use `indw start` to install it, or `indw run-once` to test)") - return nil - } - tailArgs := []string{"-n", "200"} - for _, a := range args { - if a == "-f" || a == "--follow" { - tailArgs = append(tailArgs, "-f") +// termSafe replaces control characters (including ESC, which starts ANSI +// sequences) with spaces, so names from INDmoney can't rewrite the terminal, +// set its title, or break tabwriter columns. +func termSafe(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' } - } - tailArgs = append(tailArgs, logFile) - c := exec.Command("tail", tailArgs...) - c.Stdout = os.Stdout - c.Stderr = os.Stderr - return c.Run() + return r + }, s) } +// ---- inspection commands ---- + func cmdState() error { d, err := config.Dir() if err != nil { @@ -622,14 +485,13 @@ func cmdPaths() error { return err } exe, _ := os.Executable() - pp, _ := plistPath() tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(tw, "binary\t%s\n", exe) fmt.Fprintf(tw, "config dir\t%s\n", d) fmt.Fprintf(tw, "config\t%s\n", filepath.Join(d, "config.yaml")) fmt.Fprintf(tw, "state\t%s\n", filepath.Join(d, "state.json")) fmt.Fprintf(tw, "agent log\t%s\n", filepath.Join(d, "agent.log")) - fmt.Fprintf(tw, "launchd plist\t%s\n", pp) - fmt.Fprintf(tw, "tokens\tmacOS Keychain (service: indmoney-watch, account: tokens)\n") + fmt.Fprintln(tw, schedulerLocation()) + fmt.Fprintf(tw, "tokens\t%s\n", store.Location) return tw.Flush() } diff --git a/cmd/indw/main_test.go b/cmd/indw/main_test.go new file mode 100644 index 0000000..368c5a1 --- /dev/null +++ b/cmd/indw/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + "unicode/utf8" +) + +func TestTruncIsRuneSafeAndStripsControls(t *testing.T) { + got := trunc("₹₹₹₹₹₹", 4) + if !utf8.ValidString(got) || got != "₹₹₹…" { + t.Errorf("trunc = %q", got) + } + if got := trunc("a\x1b]0;pwn\x07b", 40); got != "a ]0;pwn b" { + t.Errorf("trunc did not strip controls: %q", got) + } +} diff --git a/cmd/indw/menubar.go b/cmd/indw/menubar.go index d300a2f..2dac38e 100644 --- a/cmd/indw/menubar.go +++ b/cmd/indw/menubar.go @@ -7,8 +7,12 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "runtime" "sort" "strings" + "unicode" + "unicode/utf8" "github.com/abinashstack/indmoney-watch/internal/config" "github.com/abinashstack/indmoney-watch/internal/indmoney" @@ -88,11 +92,12 @@ func menubarRender(ctx context.Context) error { fmt.Printf("By asset class | color=%s\n", mb.HeaderColor) for _, inv := range snap.Investments { arrow := arrowFor(inv.ReturnPercentage) - fmt.Printf(" %s %s ₹%s %+.2f%% | color=%s font=Menlo\n", + line := fmt.Sprintf(" %s %s ₹%s %+.2f%%", arrow, padRight(inv.AssetType, 11), commaINR(inv.CurrentValue), - inv.ReturnPercentage, pickColor(mb, inv.ReturnPercentage, false)) + inv.ReturnPercentage) + fmt.Printf("%s | color=%s font=Menlo\n", sbEscape(line), pickColor(mb, inv.ReturnPercentage, false)) } } @@ -338,6 +343,9 @@ func arrowFor(pct float64) string { // menubarInstall drops a SwiftBar plugin that wraps `indw menubar`. Filename // encodes the refresh interval (`.10m.sh`) so SwiftBar polls every 10 min. func menubarInstall() error { + if runtime.GOOS != "darwin" { + return errors.New("the menu bar plugin needs SwiftBar, which is macOS-only; on " + runtime.GOOS + " use `indw status`, `indw watchlist` and the background poller (`indw start`)") + } exe, err := os.Executable() if err != nil { return err @@ -359,21 +367,25 @@ func menubarInstall() error { return err } - // Refresh cadence comes from config (e.g. "10m", "1s", "30s", "1h"). + // Refresh cadence comes from config (e.g. "10m", "1s", "30s", "1h"). It + // becomes part of the plugin filename, so it must be strictly validated: + // a value like "../../x" would otherwise write the script elsewhere. cfg, _ := config.Load() cadence := "10m" if cfg != nil && cfg.Menubar.RefreshInterval != "" { cadence = cfg.Menubar.RefreshInterval } + if !validCadence(cadence) { + return fmt.Errorf("menubar.refresh_interval %q is invalid: use a number followed by s, m, h or d (e.g. 10m)", cadence) + } pluginPath := filepath.Join(pluginsDir, "indmoney."+cadence+".sh") script := fmt.Sprintf(`#!/bin/bash # INDmoney # 0.1 # indmoney-watch # Portfolio + watchlist via INDmoney MCP -# [INDW_BIN=%s] -exec "%s" menubar -`, exe, exe) +exec %s menubar +`, shellQuote(exe)) // 0700: owner read/write/execute only. SwiftBar plugin scripts run with // your indw binary on a timer, which means anyone who can write to this @@ -403,7 +415,7 @@ exec "%s" menubar } else { fmt.Println("SwiftBar is installed. Either launch it or run:") fmt.Println(" open -a SwiftBar") - _ = exec.Command("open", "-a", "SwiftBar").Start() + _ = exec.Command("/usr/bin/open", "-a", "SwiftBar").Start() } return nil } @@ -455,24 +467,51 @@ func commaINR(v float64) string { return out } +// padRight pads s with spaces to n characters (runes, not bytes, so names +// containing ₹ or other multi-byte characters still line up). func padRight(s string, n int) string { - if len(s) >= n { + l := utf8.RuneCountInString(s) + if l >= n { return s } - return s + strings.Repeat(" ", n-len(s)) + return s + strings.Repeat(" ", n-l) } -// sbEscape escapes characters SwiftBar treats specially in line text. +// sbEscape neutralises characters SwiftBar treats specially in line text. +// Text from INDmoney (names, asset types, error bodies) flows through here, so +// it must not be able to start a new line or add attributes: a `|` would let +// it append `bash=…` (command execution on click), and a newline would let it +// emit an entirely new menu line. Control characters are replaced with spaces. func sbEscape(s string) string { - s = strings.ReplaceAll(s, "|", "¦") - return s + return strings.Map(func(r rune) rune { + switch { + case r == '|': + return '¦' + case unicode.IsControl(r): + return ' ' + } + return r + }, s) +} + +var cadenceRe = regexp.MustCompile(`^[1-9][0-9]{0,4}[smhd]$`) + +// validCadence reports whether v is a SwiftBar refresh interval that is safe +// to embed in a filename. +func validCadence(v string) bool { + return cadenceRe.MatchString(v) +} + +// shellQuote single-quotes s for safe interpolation into a bash script. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // swiftbarPluginsDir reads SwiftBar's user-configured plugin directory from its // `defaults` store. Returns "" if not set. SwiftBar's bundle id is // `com.ameba.SwiftBar` and the key is `PluginDirectory`. func swiftbarPluginsDir() string { - out, err := exec.Command("defaults", "read", "com.ameba.SwiftBar", "PluginDirectory").Output() + out, err := exec.Command("/usr/bin/defaults", "read", "com.ameba.SwiftBar", "PluginDirectory").Output() if err != nil { return "" } diff --git a/cmd/indw/menubar_test.go b/cmd/indw/menubar_test.go new file mode 100644 index 0000000..c2498c1 --- /dev/null +++ b/cmd/indw/menubar_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os/exec" + "strings" + "testing" +) + +func TestSbEscapeBlocksLineAndAttributeInjection(t *testing.T) { + in := "Stocks\nClick me | bash=/bin/sh param1=-c param2=id terminal=false\r--sub" + got := sbEscape(in) + if strings.ContainsAny(got, "|\n\r") { + t.Fatalf("sbEscape left a separator in %q", got) + } + if !strings.Contains(got, "¦ bash=") { + t.Fatalf("expected pipe to be replaced, got %q", got) + } +} + +func TestValidCadence(t *testing.T) { + for _, ok := range []string{"10m", "1s", "30s", "1h", "2d"} { + if !validCadence(ok) { + t.Errorf("validCadence(%q) = false, want true", ok) + } + } + for _, bad := range []string{"", "0m", "10", "m", "10m/../x", "../../x", "10m.sh", "1 m", "10M"} { + if validCadence(bad) { + t.Errorf("validCadence(%q) = true, want false", bad) + } + } +} + +func TestShellQuoteRoundTrips(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + for _, s := range []string{ + "/usr/local/bin/indw", + "/Users/a b/bin/indw", + `/tmp/$(touch pwned)/it's "quoted"/` + "`id`", + } { + out, err := exec.Command("bash", "-c", "printf %s "+shellQuote(s)).Output() + if err != nil { + t.Fatalf("bash: %v", err) + } + if string(out) != s { + t.Errorf("shellQuote(%q) round-tripped to %q", s, out) + } + } +} + +func TestPadRightCountsRunes(t *testing.T) { + if got := padRight("₹ab", 5); got != "₹ab " { + t.Errorf("padRight = %q", got) + } +} diff --git a/cmd/indw/scheduler_unix.go b/cmd/indw/scheduler_unix.go new file mode 100644 index 0000000..b20d3e3 --- /dev/null +++ b/cmd/indw/scheduler_unix.go @@ -0,0 +1,156 @@ +//go:build !windows + +package main + +import ( + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/abinashstack/indmoney-watch/internal/config" +) + +// ---- launchd ---- + +// plistEscape XML-escapes a string for safe substitution inside a +// … element of the launchd plist we generate. The two values +// we substitute (`exe` from os.Executable, `logFile` under $HOME/.config) are +// trusted in normal use, but a path containing `<` or `&` would corrupt the +// plist, and a maliciously-crafted path could close the tag and +// inject directives like RunAtLoad=true. Defense in depth: cheaper to escape +// every substitution than to reason about whether each input is safe. +func plistEscape(s string) string { + var buf strings.Builder + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} + +func plistPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist"), nil +} + +func cmdStart() error { + exe, err := os.Executable() + if err != nil { + return err + } + exe, _ = filepath.Abs(exe) + logDir, err := config.Dir() + if err != nil { + return err + } + logFile := filepath.Join(logDir, "agent.log") + + plist := fmt.Sprintf(` + + + + Label%s + ProgramArguments + + %s + run-once + + StartCalendarInterval + +%s + + StandardOutPath%s + StandardErrorPath%s + EnvironmentVariables + + PATH/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin + + + +`, launchdLabel, plistEscape(exe), calendarSlots(time.Now(), time.Local), plistEscape(logFile), plistEscape(logFile)) + + pp, err := plistPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(pp), 0o755); err != nil { + return err + } + if err := os.WriteFile(pp, []byte(plist), 0o644); err != nil { + return err + } + // Bootstrap. + uid := os.Getuid() + target := fmt.Sprintf("gui/%d", uid) + _ = exec.Command("/bin/launchctl", "bootout", target, pp).Run() + out, err := exec.Command("/bin/launchctl", "bootstrap", target, pp).CombinedOutput() + if err != nil { + return fmt.Errorf("launchctl bootstrap: %w (%s)", err, strings.TrimSpace(string(out))) + } + fmt.Println("Installed launchd agent:", pp) + fmt.Println("Logs:", logFile) + fmt.Printf("It will run every %d minutes between 09:00–16:00 IST, Mon–Fri.\n", int(pollEvery.Minutes())) + if _, off := time.Now().Zone(); off != 5*3600+30*60 { + fmt.Println("Note: the schedule is converted to your local time zone at install time.") + fmt.Println("If your clock changes for daylight saving, re-run `indw start`.") + } + return nil +} + +func cmdStop() error { + pp, err := plistPath() + if err != nil { + return err + } + uid := os.Getuid() + target := fmt.Sprintf("gui/%d", uid) + _ = exec.Command("/bin/launchctl", "bootout", target, pp).Run() + if err := os.Remove(pp); err != nil && !os.IsNotExist(err) { + return err + } + fmt.Println("Removed launchd agent.") + return nil +} + +// calendarSlots returns StartCalendarInterval entries for every pollEvery +// between 09:00 and 16:00 IST on Mon–Fri, expressed in host-local time +// (launchd only understands local time). +// +// Each slot is converted individually, weekday included: for hosts far from +// IST the market window falls on a different local day (09:00 IST Monday is +// Sunday evening in the US), so reusing the IST weekday would schedule polls +// on the wrong days. The conversion uses the IST week containing now, so the +// UTC offset matches the host's current daylight-saving state. +func calendarSlots(now time.Time, local *time.Location) string { + istLoc, err := time.LoadLocation("Asia/Kolkata") + if err != nil { + istLoc = time.FixedZone("IST", 5*3600+30*60) + } + t := now.In(istLoc) + daysSinceMonday := (int(t.Weekday()) + 6) % 7 + monday := time.Date(t.Year(), t.Month(), t.Day()-daysSinceMonday, 0, 0, 0, 0, istLoc) + + var sb strings.Builder + for d := 0; d < 5; d++ { // Mon–Fri IST + start := monday.AddDate(0, 0, d).Add(9 * time.Hour) + end := monday.AddDate(0, 0, d).Add(16 * time.Hour) + for slot := start; !slot.After(end); slot = slot.Add(pollEvery) { + lt := slot.In(local) + fmt.Fprintf(&sb, + " Weekday%dHour%dMinute%d\n", + int(lt.Weekday()), lt.Hour(), lt.Minute(), + ) + } + } + return sb.String() +} + +// schedulerLocation describes the installed scheduler entry for `indw paths`. +func schedulerLocation() string { + pp, _ := plistPath() + return "launchd plist\t" + pp +} diff --git a/cmd/indw/scheduler_unix_test.go b/cmd/indw/scheduler_unix_test.go new file mode 100644 index 0000000..c2e0b6c --- /dev/null +++ b/cmd/indw/scheduler_unix_test.go @@ -0,0 +1,66 @@ +//go:build !windows + +package main + +import ( + "regexp" + "strconv" + "testing" + "time" +) + +var slotRe = regexp.MustCompile(`Weekday(\d)Hour(\d+)Minute(\d+)`) + +type slot struct{ wd, h, m int } + +func parseSlots(t *testing.T, s string) []slot { + t.Helper() + var out []slot + for _, m := range slotRe.FindAllStringSubmatch(s, -1) { + wd, _ := strconv.Atoi(m[1]) + h, _ := strconv.Atoi(m[2]) + mi, _ := strconv.Atoi(m[3]) + out = append(out, slot{wd, h, mi}) + } + return out +} + +func mustLoc(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Skipf("tzdata for %s unavailable: %v", name, err) + } + return loc +} + +func TestCalendarSlotsIST(t *testing.T) { + ist := mustLoc(t, "Asia/Kolkata") + slots := parseSlots(t, calendarSlots(time.Date(2026, 10, 7, 12, 0, 0, 0, ist), ist)) + // 09:00–16:00 inclusive every 10 min = 43 per day, Mon–Fri. + if len(slots) != 43*5 { + t.Fatalf("got %d slots, want %d", len(slots), 43*5) + } + first, last := slots[0], slots[len(slots)-1] + if first != (slot{1, 9, 0}) || last != (slot{5, 16, 0}) { + t.Fatalf("first=%v last=%v", first, last) + } +} + +func TestCalendarSlotsUSUsesLocalWeekday(t *testing.T) { + la := mustLoc(t, "America/Los_Angeles") + // October: PDT (UTC-7). 09:00 IST Monday = 20:30 Sunday PDT; + // 16:00 IST Friday = 03:30 Friday PDT. + slots := parseSlots(t, calendarSlots(time.Date(2026, 10, 7, 12, 0, 0, 0, la), la)) + if slots[0] != (slot{0, 20, 30}) { + t.Errorf("first slot = %v, want Sunday 20:30", slots[0]) + } + if got := slots[len(slots)-1]; got != (slot{5, 3, 30}) { + t.Errorf("last slot = %v, want Friday 03:30", got) + } + // January: PST (UTC-8) — schedule follows the current offset. + slots = parseSlots(t, calendarSlots(time.Date(2026, 1, 7, 12, 0, 0, 0, la), la)) + if slots[0] != (slot{0, 19, 30}) { + t.Errorf("winter first slot = %v, want Sunday 19:30", slots[0]) + } +} diff --git a/cmd/indw/scheduler_windows.go b/cmd/indw/scheduler_windows.go new file mode 100644 index 0000000..6b264e4 --- /dev/null +++ b/cmd/indw/scheduler_windows.go @@ -0,0 +1,196 @@ +package main + +import ( + "bytes" + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode/utf16" + + "github.com/abinashstack/indmoney-watch/internal/config" +) + +// taskName is the Task Scheduler entry for the poller (under the root folder). +const taskName = "indmoney-watch" + +func system32(exe string) string { + root := os.Getenv("SystemRoot") + if root == "" { + root = `C:\Windows` + } + return filepath.Join(root, "System32", exe) +} + +func xmlEscape(s string) string { + var buf strings.Builder + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} + +// taskXML builds the Task Scheduler definition: every pollEvery from 09:00 to +// 16:00 IST, Mon–Fri, expressed in local time (like launchd, Task Scheduler +// triggers are local). The weekday is converted along with the time, so hosts +// where the IST window falls on a different local day still poll on the right +// days. The conversion uses the IST week containing now, so the offset +// matches the host's current daylight-saving state. +// +// The poller runs under `conhost --headless` so no console window flashes up +// every few minutes, only when the user is logged on (toasts need their +// session), even on battery, never overlapping, and is killed after 5 min. +func taskXML(exe string, now time.Time, local *time.Location) string { + istLoc, err := time.LoadLocation("Asia/Kolkata") + if err != nil { + istLoc = time.FixedZone("IST", 5*3600+30*60) // Windows has no tzdata + } + t := now.In(istLoc) + daysSinceMonday := (int(t.Weekday()) + 6) % 7 + monday := time.Date(t.Year(), t.Month(), t.Day()-daysSinceMonday, 9, 0, 0, 0, istLoc) + + first := monday.In(local) + var days strings.Builder + for d := 0; d < 5; d++ { + days.WriteString("<" + monday.AddDate(0, 0, d).In(local).Weekday().String() + "/>") + } + window := 7*time.Hour + time.Minute // 09:00 → 16:00 inclusive + + return fmt.Sprintf(` + + + indmoney-watch: poll INDmoney and raise alerts during Indian market hours + + + + + PT%dM + PT%dM + false + + %s + true + + %s + 1 + + + + + + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + false + PT5M + true + + + + %s + %s + + + +`, + int(pollEvery.Minutes()), int(window.Minutes()), + first.Format("2006-01-02T15:04:05"), + days.String(), + xmlEscape(system32("conhost.exe")), + // Windows paths can't contain '"', so quoting exe is sufficient. + xmlEscape(`--headless "`+exe+`" run-once --log`), + ) +} + +// utf16File encodes s as UTF-16LE with a BOM — the encoding schtasks /XML +// reliably accepts. +func utf16File(s string) []byte { + u := utf16.Encode([]rune(s)) + var b bytes.Buffer + b.Write([]byte{0xFF, 0xFE}) + for _, c := range u { + b.WriteByte(byte(c)) + b.WriteByte(byte(c >> 8)) + } + return b.Bytes() +} + +// registerTask creates (or replaces) the named task from its XML definition. +func registerTask(name, def string) error { + d, err := config.Dir() + if err != nil { + return err + } + f, err := os.CreateTemp(d, "task-*.xml") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err := f.Write(utf16File(def)); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + out, err := exec.Command(system32("schtasks.exe"), "/Create", "/TN", name, "/XML", f.Name(), "/F").CombinedOutput() + if err != nil { + return fmt.Errorf("schtasks /Create: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func deleteTask(name string) error { + out, err := exec.Command(system32("schtasks.exe"), "/Delete", "/TN", name, "/F").CombinedOutput() + if err != nil { + return fmt.Errorf("schtasks /Delete: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func taskExists(name string) bool { + return exec.Command(system32("schtasks.exe"), "/Query", "/TN", name).Run() == nil +} + +func cmdStart() error { + exe, err := os.Executable() + if err != nil { + return err + } + exe, _ = filepath.Abs(exe) + if err := registerTask(taskName, taskXML(exe, time.Now(), time.Local)); err != nil { + return err + } + logFile, _ := agentLogPath() + fmt.Println("Installed scheduled task:", taskName, "(Task Scheduler → Task Scheduler Library)") + fmt.Println("Logs:", logFile) + fmt.Printf("It will run every %d minutes between 09:00–16:00 IST, Mon–Fri, while you're logged on.\n", int(pollEvery.Minutes())) + if _, off := time.Now().Zone(); off != 5*3600+30*60 { + fmt.Println("Note: the schedule is converted to your local time zone at install time.") + fmt.Println("If your clock changes for daylight saving, re-run `indw start`.") + } + return nil +} + +func cmdStop() error { + if !taskExists(taskName) { + fmt.Println("No scheduled task installed.") + return nil + } + if err := deleteTask(taskName); err != nil { + return err + } + fmt.Println("Removed scheduled task.") + return nil +} + +// schedulerLocation describes the installed scheduler entry for `indw paths`. +func schedulerLocation() string { + return "scheduled task\t" + taskName + " (Task Scheduler Library)" +} diff --git a/cmd/indw/scheduler_windows_test.go b/cmd/indw/scheduler_windows_test.go new file mode 100644 index 0000000..d7a7fe9 --- /dev/null +++ b/cmd/indw/scheduler_windows_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "encoding/xml" + "fmt" + "os/exec" + "strings" + "testing" + "time" + "unicode/utf16" +) + +type taskDef struct { + StartBoundary string `xml:"Triggers>CalendarTrigger>StartBoundary"` + Interval string `xml:"Triggers>CalendarTrigger>Repetition>Interval"` + Duration string `xml:"Triggers>CalendarTrigger>Repetition>Duration"` + DaysOfWeek struct { + Days []xmlTag `xml:",any"` + } `xml:"Triggers>CalendarTrigger>ScheduleByWeek>DaysOfWeek"` + Command string `xml:"Actions>Exec>Command"` + Arguments string `xml:"Actions>Exec>Arguments"` + OnBatteries bool `xml:"Settings>DisallowStartIfOnBatteries"` +} + +type xmlTag struct{ XMLName xml.Name } + +func parseTask(t *testing.T, s string) taskDef { + t.Helper() + // encoding/xml refuses non-UTF-8 declarations; s is already a Go string. + s = strings.Replace(s, ` encoding="UTF-16"`, "", 1) + var d taskDef + if err := xml.Unmarshal([]byte(s), &d); err != nil { + t.Fatalf("task XML: %v\n%s", err, s) + } + return d +} + +func dayNames(d taskDef) string { + var n []string + for _, x := range d.DaysOfWeek.Days { + n = append(n, x.XMLName.Local[:3]) + } + return strings.Join(n, ",") +} + +func TestTaskXMLSchedule(t *testing.T) { + la, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + t.Skipf("tzdata unavailable: %v", err) + } + d := parseTask(t, taskXML(`C:\Users\A & B\\indw.exe`, time.Date(2026, 10, 7, 12, 0, 0, 0, la), la)) + if d.StartBoundary != "2026-10-04T20:30:00" || dayNames(d) != "Sun,Mon,Tue,Wed,Thu" { + t.Errorf("LA schedule: start=%s days=%s", d.StartBoundary, dayNames(d)) + } + if d.Interval != "PT10M" || d.Duration != "PT421M" || d.OnBatteries { + t.Errorf("repetition/settings: %+v", d) + } + if d.Arguments != `--headless "C:\Users\A & B\\indw.exe" run-once --log` { + t.Errorf("arguments = %q", d.Arguments) + } +} + +// TestRegisterTaskRoundTrip registers a real task (pointing at a nonexistent +// exe, so it can't do anything if it fires) under a throwaway name, reads it back from Task +// Scheduler, and deletes it. This is what catches XML Task Scheduler rejects. +// decodeConsole returns schtasks output as a string; it may be UTF-16LE +// (with or without BOM) or 8-bit depending on Windows version. +func decodeConsole(b []byte) string { + b = bytes.TrimPrefix(b, []byte{0xFF, 0xFE}) + if bytes.IndexByte(b, 0) < 0 { + return string(b) + } + u := make([]uint16, len(b)/2) + for i := range u { + u[i] = uint16(b[2*i]) | uint16(b[2*i+1])<<8 + } + return string(utf16.Decode(u)) +} + +func TestRegisterTaskRoundTrip(t *testing.T) { + name := fmt.Sprintf("indmoney-watch-test-%d", time.Now().UnixNano()) + exe := `C:\nonexistent\indw test\indw.exe` + if err := registerTask(name, taskXML(exe, time.Now(), time.Local)); err != nil { + t.Fatalf("registerTask: %v", err) + } + t.Cleanup(func() { _ = deleteTask(name) }) + + if !taskExists(name) { + t.Fatal("task not found after registration") + } + out, err := exec.Command(system32("schtasks.exe"), "/Query", "/TN", name, "/XML").Output() + if err != nil { + t.Fatalf("schtasks /Query: %v", err) + } + got := decodeConsole(out) + for _, want := range []string{`run-once --log`, `indw test\indw.exe`, `PT10M`, `IgnoreNew`, `conhost.exe`} { + if !strings.Contains(got, want) { + t.Errorf("registered task XML missing %q:\n%s", want, got) + } + } + + if err := deleteTask(name); err != nil { + t.Fatalf("deleteTask: %v", err) + } + if taskExists(name) { + t.Fatal("task still exists after delete") + } +} diff --git a/go.mod b/go.mod index 9eb35aa..d6fee4d 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,7 @@ module github.com/abinashstack/indmoney-watch -go 1.26.4 +go 1.26.6 require gopkg.in/yaml.v3 v3.0.1 + +require golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index a62c313..95dce5c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/alert/engine.go b/internal/alert/engine.go index ee58d86..5771b31 100644 --- a/internal/alert/engine.go +++ b/internal/alert/engine.go @@ -20,7 +20,7 @@ type Engine struct { } func NewEngine(api *indmoney.API, cfg *config.Config, st *state.State) *Engine { - return &Engine{api: api, cfg: cfg, st: st, notify: notify.MacBanner} + return &Engine{api: api, cfg: cfg, st: st, notify: notify.Banner} } func (e *Engine) Run(ctx context.Context) error { @@ -120,11 +120,10 @@ func (e *Engine) Run(ctx context.Context) error { if cc.DueDate == "" { continue } - due, err := time.Parse("2006-01-02", cc.DueDate) + daysLeft, err := daysUntil(cc.DueDate, now) if err != nil { continue } - daysLeft := int(math.Floor(time.Until(due).Hours() / 24)) if daysLeft >= 0 && daysLeft <= e.cfg.CreditCardDueWarningDays { e.fire(now, "cc-due:"+cc.Name+":"+cc.DueDate, fmt.Sprintf("%s due in %dd", cc.Name, daysLeft), @@ -140,6 +139,7 @@ func (e *Engine) Run(ctx context.Context) error { e.checkSIPs(ctx, now) } + e.pruneLastFired(now) e.st.LastSnapshot = now return state.Save(e.st) } @@ -155,7 +155,12 @@ func (e *Engine) checkIndianWatchlist(ctx context.Context, now time.Time) { } indKeys := make([]string, 0, len(stocks)) for _, s := range stocks { - indKeys = append(indKeys, s.IndKey) + if s.IndKey != "" { + indKeys = append(indKeys, s.IndKey) + } + } + if len(indKeys) == 0 { + return } details, err := e.api.IndianStockDetails(ctx, indKeys) if err != nil { @@ -280,8 +285,7 @@ func (e *Engine) applySIPs(now time.Time, kind string, sips []indmoney.SIP) { // 3. Due-soon — fire when next_execution_date is within the window. // Date-scoped alert key so it fires once per installment cycle. if e.cfg.SIPDueWarningDays > 0 && nextDate != "" { - if due, err := time.Parse("2006-01-02", nextDate); err == nil { - daysLeft := int(math.Floor(time.Until(due).Hours() / 24)) + if daysLeft, err := daysUntil(nextDate, now); err == nil { if daysLeft >= 0 && daysLeft <= e.cfg.SIPDueWarningDays { e.fire(now, "sip-due:"+key+":"+nextDate, fmt.Sprintf("SIP %s due in %dd", name, daysLeft), @@ -344,6 +348,41 @@ func (e *Engine) fire(now time.Time, key, title, subtitle, msg string) { } } +// daysUntil returns the number of calendar days from now's local date to +// date (YYYY-MM-DD): 0 means today, 1 tomorrow, negative means past. +// +// Both sides are compared as local calendar dates. Parsing with time.Parse +// would yield midnight UTC (05:30 IST), making a bill due tomorrow read as +// "due in 0d" and one due today read as overdue during market hours. +func daysUntil(date string, now time.Time) (int, error) { + loc := now.Location() + due, err := time.ParseInLocation("2006-01-02", date, loc) + if err != nil { + return 0, err + } + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + // Round, not truncate: a DST transition makes a calendar day 23 or 25 h. + return int(math.Round(due.Sub(today).Hours() / 24)), nil +} + +// lastFiredRetention bounds how long debounce entries are kept. Keys such as +// cc-due:: and sip-due:: are unique per cycle, so +// without pruning state.json grows forever. Anything older than the largest +// window we care about (debounce, or the 6 h needs-login cooldown) is dead. +const lastFiredRetention = 30 * 24 * time.Hour + +func (e *Engine) pruneLastFired(now time.Time) { + keep := lastFiredRetention + if d := time.Duration(e.cfg.DebounceMinutes) * time.Minute; d > keep { + keep = d + } + for k, t := range e.st.LastFired { + if now.Sub(t) > keep { + delete(e.st.LastFired, k) + } + } +} + func pctReturn(curr, inv float64) float64 { if inv == 0 { return 0 diff --git a/internal/alert/engine_test.go b/internal/alert/engine_test.go new file mode 100644 index 0000000..ad3ebc2 --- /dev/null +++ b/internal/alert/engine_test.go @@ -0,0 +1,47 @@ +package alert + +import ( + "testing" + "time" + + "github.com/abinashstack/indmoney-watch/internal/config" + "github.com/abinashstack/indmoney-watch/internal/state" +) + +func TestDaysUntilUsesLocalCalendarDates(t *testing.T) { + ist := time.FixedZone("IST", 5*3600+30*60) + now := time.Date(2026, 10, 4, 10, 0, 0, 0, ist) // market hours + cases := map[string]int{ + "2026-10-04": 0, // due today — previously -1 (never alerted) + "2026-10-05": 1, // due tomorrow — previously reported as 0 + "2026-10-07": 3, + "2026-10-03": -1, + } + for date, want := range cases { + got, err := daysUntil(date, now) + if err != nil { + t.Fatalf("daysUntil(%s): %v", date, err) + } + if got != want { + t.Errorf("daysUntil(%s) = %d, want %d", date, got, want) + } + } + if _, err := daysUntil("not-a-date", now); err == nil { + t.Error("expected parse error") + } +} + +func TestPruneLastFired(t *testing.T) { + now := time.Now() + st := state.New() + st.LastFired["old"] = now.Add(-31 * 24 * time.Hour) + st.LastFired["recent"] = now.Add(-time.Hour) + e := &Engine{cfg: config.Defaults(), st: st} + e.pruneLastFired(now) + if _, ok := st.LastFired["old"]; ok { + t.Error("old entry not pruned") + } + if _, ok := st.LastFired["recent"]; !ok { + t.Error("recent entry pruned") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6fb4cce..2cef980 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "gopkg.in/yaml.v3" ) @@ -133,12 +134,22 @@ func Defaults() *Config { } } +// Dir returns the per-user config directory, creating it if needed: +// ~/.config/indmoney-watch on macOS/Linux, %AppData%\indmoney-watch on +// Windows (where the folder is already private to the user by ACL; the +// 0700 mode below only applies on Unix). func Dir() (string, error) { - home, err := os.UserHomeDir() + base, err := os.UserHomeDir() if err != nil { return "", err } - d := filepath.Join(home, ".config", "indmoney-watch") + base = filepath.Join(base, ".config") + if runtime.GOOS == "windows" { + if base, err = os.UserConfigDir(); err != nil { + return "", err + } + } + d := filepath.Join(base, "indmoney-watch") if err := os.MkdirAll(d, 0o700); err != nil { return "", err } diff --git a/internal/indmoney/api.go b/internal/indmoney/api.go index 45d6c9b..1cda90f 100644 --- a/internal/indmoney/api.go +++ b/internal/indmoney/api.go @@ -115,17 +115,29 @@ type Watchlist struct { Watchlists []WatchlistGroup `json:"watchlists"` } -// AllStocks flattens entries across every named watchlist, deduplicating by -// ind_key (a stock can appear in multiple watchlists). +// AllStocks flattens entries across every named watchlist, deduplicating +// (a stock can appear in multiple watchlists). Entries are keyed by ind_key, +// falling back to ticker; an entry with neither is kept as-is. Keying on an +// empty ind_key would collapse every ticker-only entry (e.g. US stocks) into +// the first one. func (w *Watchlist) AllStocks() []WatchlistEntry { seen := map[string]bool{} var out []WatchlistEntry for _, g := range w.Watchlists { for _, s := range g.Stocks { - if seen[s.IndKey] { - continue + var key string + switch { + case s.IndKey != "": + key = "ind:" + s.IndKey + case s.Ticker != "": + key = "ticker:" + s.Ticker + } + if key != "" { + if seen[key] { + continue + } + seen[key] = true } - seen[s.IndKey] = true out = append(out, s) } } diff --git a/internal/indmoney/api_test.go b/internal/indmoney/api_test.go new file mode 100644 index 0000000..b184f59 --- /dev/null +++ b/internal/indmoney/api_test.go @@ -0,0 +1,14 @@ +package indmoney + +import "testing" + +func TestAllStocksKeepsTickerOnlyEntries(t *testing.T) { + w := &Watchlist{Watchlists: []WatchlistGroup{ + {Stocks: []WatchlistEntry{{Ticker: "AAPL"}, {Ticker: "MSFT"}, {IndKey: "INE1"}}}, + {Stocks: []WatchlistEntry{{Ticker: "AAPL"}, {IndKey: "INE1"}, {}}}, + }} + got := w.AllStocks() + if len(got) != 4 { // AAPL, MSFT, INE1, and the keyless entry + t.Fatalf("AllStocks returned %d entries: %+v", len(got), got) + } +} diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go index 3236b6c..8c87c2b 100644 --- a/internal/mcpclient/client.go +++ b/internal/mcpclient/client.go @@ -27,6 +27,17 @@ type TokenSource interface { AccessToken(ctx context.Context) (string, error) } +// ForceRefresher is optionally implemented by a TokenSource that can replace +// an access token the server rejected before its advertised expiry. +type ForceRefresher interface { + ForceRefresh(ctx context.Context) error +} + +// maxResponse caps how much of a single MCP response we buffer. Portfolio and +// watchlist payloads are tens of KB; 16 MiB bounds memory if the upstream +// misbehaves without truncating any realistic response. +const maxResponse = 16 << 20 + func New(endpoint string, ts TokenSource) *Client { return &Client{ endpoint: endpoint, @@ -163,6 +174,23 @@ func (c *Client) notify(ctx context.Context, method string, params any) error { } func (c *Client) do(ctx context.Context, body []byte) ([]byte, error) { + respBody, err := c.doOnce(ctx, body) + if err != ErrUnauthorized { + return respBody, err + } + // The token was rejected before its advertised expiry (revoked, or + // rotated by another process). Refresh once and retry. + fr, ok := c.tokenSource.(ForceRefresher) + if !ok { + return nil, err + } + if rerr := fr.ForceRefresh(ctx); rerr != nil { + return nil, fmt.Errorf("%w (refresh after 401: %w)", ErrUnauthorized, rerr) + } + return c.doOnce(ctx, body) +} + +func (c *Client) doOnce(ctx context.Context, body []byte) ([]byte, error) { tok, err := c.tokenSource.AccessToken(ctx) if err != nil { return nil, fmt.Errorf("get access token: %w", err) @@ -185,10 +213,13 @@ func (c *Client) do(ctx context.Context, body []byte) ([]byte, error) { if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" { c.sessionID = sid } - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse+1)) if err != nil { return nil, err } + if len(respBody) > maxResponse { + return nil, fmt.Errorf("response exceeds %d bytes", maxResponse) + } if resp.StatusCode == 401 { return nil, ErrUnauthorized } diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go new file mode 100644 index 0000000..280f499 --- /dev/null +++ b/internal/mcpclient/client_test.go @@ -0,0 +1,59 @@ +package mcpclient + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeTokens struct { + tok string + forced int + newToken string +} + +func (f *fakeTokens) AccessToken(context.Context) (string, error) { return f.tok, nil } +func (f *fakeTokens) ForceRefresh(context.Context) error { + f.forced++ + f.tok = f.newToken + return nil +} + +func TestRetriesOnceAfter401WithRefreshedToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fresh" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`) + })) + defer srv.Close() + + ts := &fakeTokens{tok: "revoked", newToken: "fresh"} + c := New(srv.URL, ts) + if _, err := c.Raw(context.Background(), "ping", nil); err != nil { + t.Fatalf("Raw: %v", err) + } + if ts.forced != 1 { + t.Fatalf("ForceRefresh called %d times, want 1", ts.forced) + } +} + +func TestPersistent401IsNotRetriedForever(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := New(srv.URL, &fakeTokens{tok: "a", newToken: "b"}) + if _, err := c.Raw(context.Background(), "ping", nil); err != ErrUnauthorized { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } + if calls != 2 { + t.Fatalf("server hit %d times, want 2", calls) + } +} diff --git a/internal/notify/macos.go b/internal/notify/banner_unix.go similarity index 83% rename from internal/notify/macos.go rename to internal/notify/banner_unix.go index a8ffc41..9e04432 100644 --- a/internal/notify/macos.go +++ b/internal/notify/banner_unix.go @@ -1,3 +1,5 @@ +//go:build !windows + package notify import ( @@ -6,7 +8,7 @@ import ( "strings" ) -// MacBanner fires a native macOS notification banner via osascript. +// Banner fires a native macOS notification banner via osascript. // // Strings are passed through environment variables and read inside the // AppleScript via `system attribute`, NOT concatenated into the script body. @@ -15,13 +17,13 @@ import ( // escape the string literal because they're never substituted into the // script source. INDmoney is a trusted-but-still-untrusted upstream; defense // in depth is cheap here. -func MacBanner(title, subtitle, message string) error { +func Banner(title, subtitle, message string) error { const script = `display notification (system attribute "INDW_MSG") ` + `with title (system attribute "INDW_TITLE") ` + `subtitle (system attribute "INDW_SUBTITLE") ` + `sound name "Submarine"` - cmd := exec.Command("osascript", "-e", script) + cmd := exec.Command("/usr/bin/osascript", "-e", script) cmd.Env = append(cmd.Environ(), "INDW_TITLE="+title, "INDW_SUBTITLE="+subtitle, diff --git a/internal/notify/banner_windows.go b/internal/notify/banner_windows.go new file mode 100644 index 0000000..3af41c1 --- /dev/null +++ b/internal/notify/banner_windows.go @@ -0,0 +1,75 @@ +package notify + +import ( + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "unicode/utf16" +) + +// toastScript shows a Windows toast notification via WinRT from Windows +// PowerShell 5.1 (present on every Windows 10/11 install). +// +// As with the macOS osascript path, strings are passed through environment +// variables and never spliced into the script source, so text from INDmoney +// can't inject PowerShell. They are XML-escaped before going into the toast +// template so they can't inject toast markup either. The notifier uses +// PowerShell's own AppUserModelID, which is registered on every system, so +// no Start-menu shortcut has to be installed first. Progress output is +// silenced: with redirected output PowerShell otherwise writes progress +// records ("Preparing modules for first use") to stderr as CLIXML. +const toastScript = `$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null +function Esc([string]$s) { [System.Security.SecurityElement]::Escape($s) } +$t = Esc $env:INDW_TITLE +$s = Esc $env:INDW_SUBTITLE +$m = Esc $env:INDW_MSG +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml("$t$s$m") +$toast = New-Object Windows.UI.Notifications.ToastNotification $xml +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe').Show($toast) +` + +// powershellExe returns the absolute path to Windows PowerShell so a +// powershell.exe planted earlier in %PATH% is never picked up. +func powershellExe() string { + root := os.Getenv("SystemRoot") + if root == "" { + root = `C:\Windows` + } + return filepath.Join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") +} + +// encodeCommand encodes a script for powershell -EncodedCommand (base64 of +// UTF-16LE), which sidesteps every command-line quoting rule. +func encodeCommand(script string) string { + u := utf16.Encode([]rune(script)) + b := make([]byte, 2*len(u)) + for i, c := range u { + b[2*i] = byte(c) + b[2*i+1] = byte(c >> 8) + } + return base64.StdEncoding.EncodeToString(b) +} + +// Banner fires a native Windows toast notification. +func Banner(title, subtitle, message string) error { + cmd := exec.Command(powershellExe(), + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", "-EncodedCommand", encodeCommand(toastScript)) + cmd.Env = append(cmd.Environ(), + "INDW_TITLE="+title, + "INDW_SUBTITLE="+subtitle, + "INDW_MSG="+message, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("toast: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/notify/banner_windows_test.go b/internal/notify/banner_windows_test.go new file mode 100644 index 0000000..b8b72aa --- /dev/null +++ b/internal/notify/banner_windows_test.go @@ -0,0 +1,55 @@ +package notify + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +// runPS runs script in Windows PowerShell with extra environment variables +// and returns stdout. Stderr is kept separate: PowerShell may write progress +// records there as CLIXML, which isn't a failure. +func runPS(t *testing.T, script string, env ...string) string { + t.Helper() + cmd := exec.Command(powershellExe(), "-NoProfile", "-NonInteractive", "-EncodedCommand", + encodeCommand("$ProgressPreference = 'SilentlyContinue'\n"+script)) + cmd.Env = append(cmd.Environ(), env...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + t.Fatalf("powershell: %v\nstdout: %s\nstderr: %s", err, out, stderr.String()) + } + return strings.TrimSpace(string(out)) +} + +// TestToastScriptParses checks the encoded script reaches PowerShell intact +// and is syntactically valid, without actually showing a toast (CI runners +// have no notification centre to show it in). +func TestToastScriptParses(t *testing.T) { + check := `$src = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('` + encodeCommand(toastScript) + `')) +$errs = $null +[System.Management.Automation.PSParser]::Tokenize($src, [ref]$errs) | Out-Null +if ($errs.Count -gt 0) { $errs | ForEach-Object { Write-Output $_.Message }; exit 1 } +if ($src -ne $env:EXPECTED) { Write-Output 'round-trip mismatch'; exit 1 } +Write-Output 'ok'` + if out := runPS(t, check, "EXPECTED="+toastScript); out != "ok" { + t.Fatalf("toast script check: %s", out) + } +} + +// TestToastEscapesHostileText runs the escaping step of the script on hostile +// input and checks the resulting toast XML is well-formed and literal. +func TestToastEscapesHostileText(t *testing.T) { + hostile := `pwn"; $(Start-Process calc) & ` + check := `function Esc([string]$s) { [System.Security.SecurityElement]::Escape($s) } +$m = Esc $env:INDW_MSG +$doc = New-Object System.Xml.XmlDocument +$doc.LoadXml("$m") +if ($doc.toast.text -ne $env:INDW_MSG) { Write-Output 'mismatch'; exit 1 } +Write-Output 'ok'` + if out := runPS(t, check, "INDW_MSG="+hostile); out != "ok" { + t.Fatalf("escaping check: %s", out) + } +} diff --git a/internal/oauth/browser_unix.go b/internal/oauth/browser_unix.go new file mode 100644 index 0000000..05fc987 --- /dev/null +++ b/internal/oauth/browser_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package oauth + +import "os/exec" + +func openBrowser(u string) error { + return exec.Command("/usr/bin/open", u).Start() +} diff --git a/internal/oauth/browser_windows.go b/internal/oauth/browser_windows.go new file mode 100644 index 0000000..2426f0c --- /dev/null +++ b/internal/oauth/browser_windows.go @@ -0,0 +1,18 @@ +package oauth + +import ( + "os" + "os/exec" + "path/filepath" +) + +// openBrowser opens u in the default browser. rundll32 receives the URL as a +// plain argument, so unlike `cmd /c start` the `&` separators in the query +// string aren't interpreted by a shell. +func openBrowser(u string) error { + root := os.Getenv("SystemRoot") + if root == "" { + root = `C:\Windows` + } + return exec.Command(filepath.Join(root, "System32", "rundll32.exe"), "url.dll,FileProtocolHandler", u).Start() +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 59d452c..62db5fb 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/json" "errors" @@ -14,7 +15,7 @@ import ( "net" "net/http" "net/url" - "os/exec" + "regexp" "strings" "sync" "time" @@ -40,6 +41,38 @@ const ( // OAuth endpoint while still letting users notice and Ctrl-C. var httpClient = &http.Client{Timeout: 30 * time.Second} +// maxBody caps how much of an OAuth endpoint response we read. Token and +// registration responses are a few hundred bytes; 1 MiB is generous while +// still bounding memory if the server misbehaves. +const maxBody = 1 << 20 + +// oauthErrCode matches a conservative subset of RFC 6749 error codes. Used +// to decide whether a server- or URL-supplied error string is safe to echo +// into logs (no newlines, no long attacker-controlled text). +var oauthErrCode = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`) + +// safeErrCode returns code if it looks like a plain OAuth error code, else a +// placeholder. Never return raw response bodies from these endpoints: they +// may contain tokens or client secrets, and errors end up in agent.log. +func safeErrCode(code string) string { + if oauthErrCode.MatchString(code) { + return code + } + return "unrecognized_error" +} + +// readOAuthError decodes the RFC 6749 §5.2 error code from a response body. +func readOAuthError(rb []byte) string { + var oe struct { + Error string `json:"error"` + } + _ = json.Unmarshal(rb, &oe) + if oe.Error == "" { + return "no error code" + } + return safeErrCode(oe.Error) +} + // ClientCreds is the result of dynamic client registration. type ClientCreds struct { ClientID string `json:"client_id"` @@ -73,16 +106,21 @@ func Register(ctx context.Context, redirectURI string) (*ClientCreds, error) { return nil, err } defer resp.Body.Close() - rb, _ := io.ReadAll(resp.Body) + rb, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, fmt.Errorf("register read: %w", err) + } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("register http %d: %s", resp.StatusCode, string(rb)) + return nil, fmt.Errorf("register http %d: %s", resp.StatusCode, readOAuthError(rb)) } var c ClientCreds if err := json.Unmarshal(rb, &c); err != nil { - return nil, fmt.Errorf("register decode: %w", err) + // Don't wrap err: json syntax errors can quote body fragments, and the + // body may carry client_secret. + return nil, errors.New("register: malformed response") } if c.ClientID == "" { - return nil, fmt.Errorf("register: empty client_id (body=%s)", string(rb)) + return nil, errors.New("register: empty client_id") } return &c, nil } @@ -124,46 +162,17 @@ func Login(ctx context.Context, creds *ClientCreds, redirectURI string) (*Tokens authURL := AuthorizeURL + "?" + q.Encode() // Start callback server. - type result struct { - code string - err error - } - resCh := make(chan result, 1) - var once sync.Once + handler, resCh := callbackHandler(state) srv := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/callback" { - http.NotFound(w, r) - return - } - gotState := r.URL.Query().Get("state") - code := r.URL.Query().Get("code") - errParam := r.URL.Query().Get("error") - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if errParam != "" { - // errParam is attacker-controllable: anyone who can navigate the - // user's browser to http://127.0.0.1:47823/callback?error=… during - // the login window injects HTML otherwise. html.EscapeString covers - // the body context (& < > " '). - _, _ = io.WriteString(w, "

Login failed

"+html.EscapeString(errParam)+"

") - once.Do(func() { resCh <- result{err: fmt.Errorf("oauth error: %s", errParam)} }) - return - } - if gotState != state { - _, _ = io.WriteString(w, "

State mismatch

") - once.Do(func() { resCh <- result{err: fmt.Errorf("state mismatch")} }) - return - } - _, _ = io.WriteString(w, "

Logged in. You can close this tab.

") - once.Do(func() { resCh <- result{code: code} }) - }), + ReadHeaderTimeout: 10 * time.Second, + Handler: handler, } go func() { _ = srv.Serve(ln) }() fmt.Printf("Opening browser for INDmoney login…\nIf it doesn't open, visit:\n %s\n\n", authURL) - _ = exec.Command("open", authURL).Start() + _ = openBrowser(authURL) - var got result + var got callbackResult select { case got = <-resCh: case <-ctx.Done(): @@ -188,6 +197,51 @@ func Login(ctx context.Context, creds *ClientCreds, redirectURI string) (*Tokens return tokens, redirectURI, nil } +// callbackResult is what the redirect handler reports back to Login. +type callbackResult struct { + code string + err error +} + +// callbackHandler serves the OAuth redirect. It reports exactly one result on +// the returned channel: the authorization code, or the error the +// authorization server sent. Requests not carrying state are ignored. +func callbackHandler(state string) (http.Handler, <-chan callbackResult) { + resCh := make(chan callbackResult, 1) + var once sync.Once + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/callback" { + http.NotFound(w, r) + return + } + gotState := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + errParam := r.URL.Query().Get("error") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Anyone who can navigate the user's browser (i.e. any web page) + // can hit this endpoint during the login window. Requests that + // don't carry our state are rejected WITHOUT ending the flow, so a + // forged ?error= or ?state= can't abort a legitimate login. + if subtle.ConstantTimeCompare([]byte(gotState), []byte(state)) != 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "

State mismatch

Ignored. Finish logging in from the original tab.

") + return + } + if errParam != "" { + _, _ = io.WriteString(w, "

Login failed

"+html.EscapeString(errParam)+"

") + once.Do(func() { resCh <- callbackResult{err: fmt.Errorf("oauth error: %s", safeErrCode(errParam))} }) + return + } + if code == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "

Missing authorization code

") + return + } + _, _ = io.WriteString(w, "

Logged in. You can close this tab.

") + once.Do(func() { resCh <- callbackResult{code: code} }) + }), resCh +} + func exchangeCode(ctx context.Context, creds *ClientCreds, redirectURI, code, verifier string) (*Tokens, error) { form := url.Values{} form.Set("grant_type", "authorization_code") @@ -230,25 +284,21 @@ func tokenRequest(ctx context.Context, form url.Values) (*Tokens, error) { return nil, err } defer resp.Body.Close() - rb, _ := io.ReadAll(resp.Body) + rb, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, fmt.Errorf("token read: %w", err) + } if resp.StatusCode >= 400 { - // Try to parse the standard OAuth error response (RFC 6749 §5.2). If - // the server says invalid_grant, the refresh token is dead and silent + // Parse the standard OAuth error response (RFC 6749 §5.2). If the + // server says invalid_grant, the refresh token is dead and silent // recovery isn't possible — wrap ErrNeedsLogin so callers can match - // via errors.Is and trigger a re-login flow. - var oe struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` + // via errors.Is and trigger a re-login flow. Only the error code is + // surfaced; the body is never echoed because it ends up in agent.log. + code := readOAuthError(rb) + if code == "invalid_grant" { + return nil, fmt.Errorf("token http %d: %s: %w", resp.StatusCode, code, ErrNeedsLogin) } - _ = json.Unmarshal(rb, &oe) - if oe.Error == "invalid_grant" { - desc := oe.ErrorDescription - if desc == "" { - desc = "refresh token rejected" - } - return nil, fmt.Errorf("token http %d: %s: %w", resp.StatusCode, desc, ErrNeedsLogin) - } - return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, string(rb)) + return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, code) } var raw struct { AccessToken string `json:"access_token"` @@ -258,7 +308,15 @@ func tokenRequest(ctx context.Context, form url.Values) (*Tokens, error) { TokenType string `json:"token_type"` } if err := json.Unmarshal(rb, &raw); err != nil { - return nil, fmt.Errorf("token decode: %w (body=%s)", err, string(rb)) + // Never include the body or the decoder error: a successful token + // response carries access_token and refresh_token. + return nil, errors.New("token: malformed response") + } + if raw.AccessToken == "" { + return nil, errors.New("token: empty access_token") + } + if raw.TokenType != "" && !strings.EqualFold(raw.TokenType, "bearer") { + return nil, fmt.Errorf("token: unsupported token_type %q", safeErrCode(raw.TokenType)) } return &Tokens{ AccessToken: raw.AccessToken, diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 0000000..5e88fe2 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,70 @@ +package oauth + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestReadOAuthErrorNeverEchoesBody(t *testing.T) { + cases := map[string]string{ + `{"error":"invalid_grant","error_description":"refresh_token=SECRET"}`: "invalid_grant", + `{"access_token":"SECRET"}`: "no error code", + `not json SECRET`: "no error code", + `{"error":"bad\nSECRET \nlog-forge"), nil)) + if strings.Contains(rec.Body.String(), "