diff --git a/.gitattributes b/.gitattributes index 7bfd22535a87fbe320445a05dfc92406f93aa4e9..4ba7d92a897f5abc60afe52f47b75dcf32e46d6f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,6 @@ clib/stb_image.h linguist-vendored *.json linguist-detectable=false *.html linguist-detectable=false public/** linguist-documentation + +# Golden snapshot files must be LF on all platforms. +tui/testdata/golden/*.txt text eol=lf diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000000000000000000000000000000000000..59bc8b49e55e2fcb3aab8d4f27ca3ed244b6c83c --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,106 @@ +name: Benchmarks + +on: + pull_request: + branches: [master] + push: + branches: [master] + +permissions: + contents: read + +jobs: + benchmark: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout PR + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.3" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Install benchstat + run: go install golang.org/x/perf/cmd/benchstat@latest + + - name: Resolve base ref + id: base + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "ref=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" + else + echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" + fi + + - name: Benchmark PR + run: | + go test -run=^$ -bench=. -benchmem -benchtime=3x -count=6 ./backend/ ./tui/ \ + | tee new.txt + + - name: Checkout base + run: git checkout ${{ steps.base.outputs.ref }} + + - name: Benchmark base + run: | + go test -run=^$ -bench=. -benchmem -benchtime=3x -count=6 ./backend/ ./tui/ \ + | tee old.txt || echo "base benchmarks failed" > old.txt + + - name: Restore PR checkout + run: git checkout ${{ github.sha }} + + - name: Compare with benchstat + run: | + set +e + benchstat old.txt new.txt | tee benchstat.txt + + - name: Classify result + run: | + python3 - <<'PY' > verdict.txt + import re + worse, better = 0, 0 + with open("benchstat.txt") as f: + for line in f: + m = re.search(r"([-+]?\d+\.\d+)%", line) + if not m: + continue + delta = float(m.group(1)) + if "ns/op" in line or "B/op" in line or "allocs/op" in line: + if delta > 3: + worse += 1 + elif delta < -3: + better += 1 + status = "neutral" + if worse > 0 and worse >= better: + status = "regression" + elif better > 0: + status = "improvement" + print(f"status={status}") + print(f"worse={worse}") + print(f"better={better}") + PY + cat verdict.txt + + - name: Record PR metadata + if: github.event_name == 'pull_request' + run: | + echo "${{ github.event.pull_request.number }}" > pr-number.txt + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmarks + path: | + old.txt + new.txt + benchstat.txt + verdict.txt + pr-number.txt + if-no-files-found: ignore diff --git a/.github/workflows/bot-bench-comment.yml b/.github/workflows/bot-bench-comment.yml new file mode 100644 index 0000000000000000000000000000000000000000..229328aaa20f4e2fbde32ed516e29ae3b13abb18 --- /dev/null +++ b/.github/workflows/bot-bench-comment.yml @@ -0,0 +1,116 @@ +name: Bot - Benchmark PR Comment + +on: + workflow_run: + workflows: ["Benchmarks"] + types: [completed] + +permissions: + contents: read + pull-requests: write + actions: read + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Download benchmarks artifact + uses: actions/github-script@v7 + with: + script: | + const run_id = context.payload.workflow_run.id; + const { data: list } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id, + }); + const art = list.artifacts.find(a => a.name === 'benchmarks'); + if (!art) { + core.setFailed('benchmarks artifact missing'); + return; + } + const dl = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: art.id, + archive_format: 'zip', + }); + const fs = require('fs'); + fs.writeFileSync('benchmarks.zip', Buffer.from(dl.data)); + + - name: Unzip + run: | + mkdir -p bench + unzip -o benchmarks.zip -d bench + ls -la bench + + - name: Post comment + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const read = (p, fallback = '') => { + try { return fs.readFileSync(p, 'utf8'); } catch (_) { return fallback; } + }; + + const prRaw = read('bench/pr-number.txt').trim(); + const pr_number = parseInt(prRaw, 10); + if (!pr_number) { + core.info('no PR number recorded; skipping'); + return; + } + + const report = read('bench/benchstat.txt', '(empty)'); + const verdict = read('bench/verdict.txt'); + const parse = key => { + const m = verdict.match(new RegExp("^" + key + "=(.+)$", "m")); + return m ? m[1].trim() : ''; + }; + const status = parse('status') || 'neutral'; + const worse = parse('worse') || '0'; + const better = parse('better') || '0'; + + const headers = { + regression: "### Benchmark report — regression detected", + improvement: "### Benchmark report — improvement detected", + neutral: "### Benchmark report — no significant change", + }; + const body = [ + headers[status] || headers.neutral, + `Metrics worse: **${worse}** · better: **${better}** (threshold: ±3%).`, + "", + "
benchstat output", + "", + "```", + report.trim() || "(empty)", + "```", + "", + "
", + "", + "auto-generated by benchmarks.yml", + ].join("\n"); + + const marker = "auto-generated by benchmarks.yml"; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr_number, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr_number, + body, + }); + } diff --git a/.github/workflows/bot-check-ci.yml b/.github/workflows/bot-check-ci.yml index 261172a39f3555385e1dd1487e8c96cb2eddfe2f..a68438e92ca0643fd58f6a046b71b78505627fde 100644 --- a/.github/workflows/bot-check-ci.yml +++ b/.github/workflows/bot-check-ci.yml @@ -2,103 +2,297 @@ name: Bot - CI Failure Notifier on: workflow_run: - workflows: ["CI"] + workflows: + - "CI" + - "Security" + - "Benchmarks" + - "Integration" types: [completed] +permissions: + contents: read + pull-requests: write + issues: write + actions: read + jobs: notify: runs-on: ubuntu-latest steps: + - name: Install log-parser dep + run: npm install --no-save --prefix /tmp/botci adm-zip + - name: Process CI Result uses: actions/github-script@v9 + env: + BOT_NODE_MODULES: /tmp/botci/node_modules with: - github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} + github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | + module.paths.unshift(process.env.BOT_NODE_MODULES); const run = context.payload.workflow_run; const owner = context.repo.owner; const repo = context.repo.repo; + const workflowName = run.name || "workflow"; + const isMasterPush = run.event === 'push' && run.head_branch === 'master'; - // 1. Robustly find the PR number (works for forks too) - let pr_number; + // Resolve PR + let pr_number = null; if (run.pull_requests && run.pull_requests.length > 0) { pr_number = run.pull_requests[0].number; } else { - // Fallback: Find PR associated with the commit SHA - const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner, - repo, - commit_sha: run.head_sha - }); - if (prs.length > 0) { - pr_number = prs[0].number; + try { + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, repo, commit_sha: run.head_sha + }); + const open = prs.find(p => p.state === 'open'); + if (open) pr_number = open.number; + else if (prs.length > 0) pr_number = prs[0].number; + } catch (e) { + console.log(`PR lookup failed: ${e.message}`); } } - if (!pr_number) { - console.log("No open PR found for this workflow run. Exiting."); + if (!pr_number && !isMasterPush) { + console.log(`No PR and not master push (event=${run.event} branch=${run.head_branch}). Exiting.`); return; } - // 2. Handle CI Failure -> Request Changes - if (run.conclusion === 'failure') { - const jobs = await github.rest.actions.listJobsForWorkflowRun({ - owner, repo, run_id: run.id - }); - - const failedJobs = jobs.data.jobs.filter(j => j.conclusion === 'failure').map(j => j.name); + const fixHints = { + lint: "Run `make lint` locally.", + 'mod-tidy': "Run `go mod tidy` and commit `go.mod`/`go.sum`.", + nix: "Run `nix flake check --no-build` locally.", + snap: "Validate `snapcraft.yaml` with `snapcraft list-plugins`.", + flatpak: "Validate the Flatpak manifest YAML structure.", + website: "Run `cd docs && npm ci && npx docusaurus build`.", + 'lua-plugins': "Run `luac -p plugins/*.lua` to check Lua syntax.", + goreleaser: "Run `goreleaser check -f .goreleaser.yml`.", + build: "Run `go build ./...` and `go test ./...` locally.", + govulncheck: "Run `govulncheck ./...` and update vulnerable deps.", + gosec: "Inspect gosec SARIF in the Security tab; fix or annotate with `//nolint:gosec` (justified).", + trivy: "Run `trivy fs .` and address CRITICAL/HIGH findings.", + codeql: "Inspect CodeQL findings in the Security tab.", + benchmark: "Inspect benchstat report on the PR; look for >3% regressions.", + 'imap + smtp e2e': "Run `docker compose -f tests/integration/docker-compose.yml up -d` then `go test -tags=integration ./tests/integration/...`.", + 'cross-platform': "Reproduce with `go test -race ./...` on the failing OS.", + fuzz: "Reproduce with `go test -fuzz= -fuzztime=30s ./`.", + }; - let message = "The CI workflow failed. Please fix the following issues locally and push again:\n\n"; - let foundSpecificInstruction = false; + function hintFor(jobName) { + const lower = jobName.toLowerCase(); + for (const key of Object.keys(fixHints)) { + if (lower.includes(key)) return fixHints[key]; + } + return null; + } - if (failedJobs.some(name => name.includes('lint'))) { - message += "- **Lint Failed**: Run `gofmt -w .` to format files and `go vet ./...` to check for vet errors.\n"; - foundSpecificInstruction = true; + async function downloadLogs() { + try { + const res = await github.rest.actions.downloadWorkflowRunLogs({ + owner, repo, run_id: run.id + }); + return Buffer.from(res.data); + } catch (e) { + console.log(`could not download logs: ${e.message}`); + return null; } - if (failedJobs.some(name => name.includes('mod-tidy'))) { - message += "- **Mod-tidy Failed**: Run `go mod tidy` locally and commit the resulting `go.mod` and `go.sum` changes.\n"; - foundSpecificInstruction = true; + } + + async function extractFailures(zipBuf) { + if (!zipBuf) return []; + let AdmZip; + try { + AdmZip = require('adm-zip'); + } catch (_) { + console.log("adm-zip not available; skipping log parse"); + return []; } - if (failedJobs.some(name => name.includes('nix'))) { - message += "- **Nix Failed**: Run `nix flake check --no-build` locally to find the issue with the flake.\n"; - foundSpecificInstruction = true; + const zip = new AdmZip(zipBuf); + const failures = []; + const goFailRe = /^FAIL\s+(\S+)/; + const testFailRe = /^\s*---\s+FAIL:\s+(\S+)/; + const buildFailRe = /^(.+\.go):(\d+):(\d+):\s+(.+)$/; + + for (const entry of zip.getEntries()) { + if (entry.isDirectory) continue; + if (!entry.entryName.endsWith('.txt')) continue; + const text = entry.getData().toString('utf8'); + const pkgs = new Set(); + const tests = new Set(); + const buildErrs = []; + for (const line of text.split('\n')) { + let m; + if ((m = line.match(testFailRe))) tests.add(m[1]); + else if ((m = line.match(goFailRe))) { + if (m[1] !== '[build') pkgs.add(m[1]); + } else if ((m = line.match(buildFailRe))) { + if (buildErrs.length < 5) buildErrs.push(`${m[1]}:${m[2]}: ${m[4]}`); + } + } + if (pkgs.size || tests.size || buildErrs.length) { + failures.push({ + file: entry.entryName, + packages: [...pkgs], + tests: [...tests], + buildErrors: buildErrs, + }); + } } + return failures; + } - if (!foundSpecificInstruction) { - message += "Please check the [CI logs](" + run.html_url + ") for more details on the failure."; + function renderFailuresSection(failures) { + if (!failures.length) return ""; + const seenPkgs = new Set(); + const seenTests = new Set(); + const seenBuild = new Set(); + for (const f of failures) { + f.packages.forEach(p => seenPkgs.add(p)); + f.tests.forEach(t => seenTests.add(t)); + f.buildErrors.forEach(b => seenBuild.add(b)); } + const lines = ["\n#### Failure details\n"]; + if (seenBuild.size) { + lines.push("**Build errors:**"); + for (const b of [...seenBuild].slice(0, 8)) lines.push(`- \`${b}\``); + lines.push(""); + } + if (seenPkgs.size) { + lines.push("**Failing packages:**"); + for (const p of [...seenPkgs].slice(0, 20)) lines.push(`- \`${p}\``); + lines.push(""); + } + if (seenTests.size) { + lines.push("**Failing tests:**"); + for (const t of [...seenTests].slice(0, 30)) lines.push(`- \`${t}\``); + lines.push(""); + } + return lines.join("\n"); + } - // Submit an official "Request Changes" review - await github.rest.pulls.createReview({ - owner, - repo, - pull_number: pr_number, - body: message, - event: 'REQUEST_CHANGES' + const marker = ``; + + async function upsertPRComment(body) { + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number: pr_number, per_page: 100 }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body + }); + console.log(`updated comment ${existing.id} on PR #${pr_number}`); + } else { + await github.rest.issues.createComment({ + owner, repo, issue_number: pr_number, body + }); + console.log(`created comment on PR #${pr_number}`); + } + } + async function deletePRCommentIfExists() { + if (!pr_number) return; + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number: pr_number, per_page: 100 + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + try { + await github.rest.issues.deleteComment({ + owner, repo, comment_id: existing.id + }); + console.log(`deleted stale comment ${existing.id}`); + } catch (e) { + // Fallback: edit instead of deleting if delete denied. + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, + body: `${marker}\n${workflowName} now passing on \`${run.head_sha.substring(0, 7)}\` ([run](${run.html_url})).` + }); + } + } } - // 3. Handle CI Success -> Dismiss Reviews - else if (run.conclusion === 'success') { - const { data: botUser } = await github.rest.users.getAuthenticated(); - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, repo, pull_number: pr_number + // ----- failure ----- + if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') { + const { data: jobsData } = await github.rest.actions.listJobsForWorkflowRun({ + owner, repo, run_id: run.id }); + const failedJobs = jobsData.jobs + .filter(j => j.conclusion === 'failure' || j.conclusion === 'timed_out') + .map(j => ({ name: j.name, url: j.html_url })); + + const zipBuf = await downloadLogs(); + const failures = await extractFailures(zipBuf); - // Find active blocking reviews left by the bot account - const botReviews = reviews.filter(r => - r.user.login === botUser.login && - r.state === 'CHANGES_REQUESTED' - ); - - // Dismiss them - for (const review of botReviews) { - await github.rest.pulls.dismissReview({ - owner, - repo, - pull_number: pr_number, - review_id: review.id, - message: 'The CI workflow has passed! Dismissing previous review.' + let lines = []; + lines.push(`### ❌ ${workflowName} failed`); + lines.push(""); + lines.push(`Commit: \`${run.head_sha.substring(0, 7)}\` · [run logs](${run.html_url})`); + lines.push(""); + if (failedJobs.length) { + lines.push("**Failed jobs:**"); + for (const j of failedJobs) { + const hint = hintFor(j.name); + lines.push(`- [${j.name}](${j.url})${hint ? ` — ${hint}` : ""}`); + } + } + lines.push(renderFailuresSection(failures)); + + const body = `${marker}\n` + lines.join("\n"); + + if (pr_number) { + await upsertPRComment(body); + } else if (isMasterPush) { + const title = `${workflowName} failed on master @ ${run.head_sha.substring(0, 7)}`; + const { data: existing } = await github.rest.issues.listForRepo({ + owner, repo, state: 'open', labels: 'ci-failure', per_page: 50 + }); + const dup = existing.find(i => i.title === title); + if (dup) { + await github.rest.issues.createComment({ + owner, repo, issue_number: dup.number, + body: `Re-run also failed: ${run.html_url}\n\n${lines.join("\n")}` + }); + } else { + await github.rest.issues.create({ + owner, repo, title, body, labels: ['ci-failure'] + }); + } + try { + await github.rest.repos.createCommitComment({ + owner, repo, commit_sha: run.head_sha, body + }); + } catch (e) { + console.log(`commit comment failed: ${e.message}`); + } + } + return; + } + + // ----- success ----- + if (run.conclusion === 'success') { + if (pr_number) { + await deletePRCommentIfExists(); + } + if (isMasterPush) { + let botLogin = null; + try { + const { data: botUser } = await github.rest.users.getAuthenticated(); + botLogin = botUser.login; + } catch (_) {} + const { data: openIssues } = await github.rest.issues.listForRepo({ + owner, repo, state: 'open', labels: 'ci-failure', per_page: 50 }); + for (const issue of openIssues) { + if (botLogin && issue.user && issue.user.login !== botLogin) continue; + if (!issue.title.includes(workflowName)) continue; + await github.rest.issues.createComment({ + owner, repo, issue_number: issue.number, + body: `Subsequent master ${workflowName} run passed: ${run.html_url}. Closing.` + }); + await github.rest.issues.update({ + owner, repo, issue_number: issue.number, state: 'closed' + }); + } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d40ff814c7a20b8d475cabad4180c697db0d147..96916be2798ea439a8a2c8cdee1af2b0f734a618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,13 @@ jobs: - name: Vet run: go vet ./... + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.12.2 + args: --timeout=10m --build-tags=integration + only-new-issues: false + mod-tidy: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000000000000000000000000000000000000..5035d4805a3694339bc4f235176f818dd926a175 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,124 @@ +name: Integration + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + imap-smtp: + name: imap + smtp e2e + runs-on: ubuntu-latest + timeout-minutes: 25 + + services: + greenmail: + image: greenmail/standalone:2.1.3 + ports: + - 3025:3025 + - 3110:3110 + - 3143:3143 + - 3465:3465 + - 3993:3993 + - 3995:3995 + - 8080:8080 + env: + GREENMAIL_OPTS: "-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose" + # No --health-cmd: greenmail/standalone image has no wget/curl. + # The runner step below polls /api/service/readiness externally instead. + + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.3" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Wait for greenmail + run: | + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:8080/api/configuration >/dev/null; then + echo "greenmail ready" + exit 0 + fi + sleep 2 + done + echo "greenmail not ready" + curl -v http://127.0.0.1:8080/api/configuration || true + exit 1 + + - name: Run integration tests + env: + MATCHA_TEST_IMAP_HOST: 127.0.0.1 + MATCHA_TEST_IMAP_PORT: "3993" + MATCHA_TEST_SMTP_PORT: "3465" + MATCHA_TEST_API_PORT: "8080" + run: | + go test -v -tags=integration -timeout=10m -count=1 ./tests/integration/... + + matrix: + name: cross-platform build matrix + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go: ["1.26.3"] + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go }} + + - name: Install system deps (linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Race-enabled unit tests + run: go test -race -timeout=10m ./... + + fuzz: + name: short fuzz + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.3" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Discover fuzz targets + id: discover + run: | + set -e + targets=$(grep -RIl --include='*_test.go' -E '^func Fuzz' . || true) + echo "found targets:" + echo "$targets" + { + echo "targets<> "$GITHUB_OUTPUT" + + - name: Run fuzz targets (30s each) + if: steps.discover.outputs.targets != '' + run: | + for file in ${{ steps.discover.outputs.targets }}; do + dir=$(dirname "$file") + for fn in $(grep -hoE '^func (Fuzz[A-Za-z0-9_]+)' "$file" | awk '{print $2}'); do + echo "=== $dir :: $fn ===" + go test -run=^$ -fuzz="^${fn}$" -fuzztime=30s "./$dir" + done + done diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000000000000000000000000000000000000..7f21faa8b4645a3a1b80763eb19ad692108ae53f --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,128 @@ +name: Security + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: read + security-events: write + pull-requests: write + +jobs: + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.3" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Run govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck -show verbose ./... + + gosec: + name: gosec + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.3" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Run gosec + uses: securego/gosec@master + env: + GOTOOLCHAIN: auto + with: + args: "-no-fail -fmt=sarif -out=gosec.sarif -severity=medium -confidence=medium -exclude=G101,G115,G204,G304,G306,G401,G501 ./..." + + - name: Upload SARIF + if: always() && hashFiles('gosec.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: gosec.sarif + category: gosec + + trivy: + name: trivy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Trivy filesystem scan + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: . + format: sarif + output: trivy.sarif + severity: CRITICAL,HIGH,MEDIUM + ignore-unfixed: true + exit-code: "0" + + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy.sarif + category: trivy + + - name: Trivy config scan + uses: aquasecurity/trivy-action@master + with: + scan-type: config + scan-ref: . + format: table + severity: CRITICAL,HIGH + exit-code: "1" + skip-dirs: docs/node_modules + + codeql: + name: codeql + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.3" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + queries: security-extended + + - name: Build + run: go build ./... + + - name: Analyze + uses: github/codeql-action/analyze@v3 + with: + category: codeql-go diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000000000000000000000000000000000000..3c933a01f6e709e638d8bcf86caf97651be2e64e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,127 @@ +version: "2" + +run: + timeout: 10m + build-tags: + - integration + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - bodyclose + - copyloopvar + - dupword + - durationcheck + - errorlint + - exhaustive + - forbidigo + - gocheckcompilerdirectives + - gochecksumtype + - goconst + - gocritic + - gocyclo + - gosec + - misspell + - nilerr + - nilnil + - noctx + - nolintlint + - prealloc + - predeclared + - reassign + - revive + - rowserrcheck + - sqlclosecheck + - tagalign + - tparallel + - unconvert + - unparam + - usestdlibvars + - wastedassign + - whitespace + + settings: + gocyclo: + min-complexity: 30 + goconst: + min-len: 4 + min-occurrences: 5 + gosec: + excludes: + - G101 + - G115 + - G204 + - G304 + - G306 + - G401 + - G501 + govet: + enable-all: true + disable: + - fieldalignment + - shadow + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: increment-decrement + - name: var-declaration + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unused-parameter + disabled: true + - name: unreachable-code + - name: redefines-builtin-id + forbidigo: + forbid: + - pattern: ^fmt\.Print.*$ + msg: "use logger instead of fmt.Print*" + + exclusions: + generated: lax + rules: + - path: _test\.go + linters: + - errcheck + - gosec + - goconst + - gocyclo + - forbidigo + - noctx + - unparam + - path: main\.go + linters: + - forbidigo + - path: cli/ + linters: + - forbidigo + - text: "weak cryptographic primitive" + linters: + - gosec + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +formatters: + enable: + - gofmt + - goimports diff --git a/backend/search_bench_test.go b/backend/search_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1cfc76e7f3628288ff198779b1d4ab106ccdadf8 --- /dev/null +++ b/backend/search_bench_test.go @@ -0,0 +1,27 @@ +package backend + +import "testing" + +func BenchmarkParseSearchQuery_Simple(b *testing.B) { + const q = "invoice" + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = ParseSearchQuery(q) + } +} + +func BenchmarkParseSearchQuery_Complex(b *testing.B) { + const q = `from:alice@example.com to:bob@example.com subject:"Q4 invoice" body:overdue since:2026-01-01 before:2026-05-01 larger:1024 misc terms` + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = ParseSearchQuery(q) + } +} + +func BenchmarkTokenizeSearchQuery(b *testing.B) { + const q = `from:alice "long quoted phrase here" subject:foo body:bar` + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = tokenizeSearchQuery(q) + } +} diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..63fe3ff22eec26b9d8f578d12d47713bfe69b2db --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,20 @@ +services: + greenmail: + image: greenmail/standalone:2.1.3 + environment: + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.all + -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.auth.disabled + -Dgreenmail.verbose + ports: + - "3025:3025" # SMTP + - "3110:3110" # POP3 + - "3143:3143" # IMAP + - "3465:3465" # SMTPS (implicit TLS) + - "3993:3993" # IMAPS (implicit TLS) + - "3995:3995" # POP3S + - "8080:8080" # REST API + # greenmail/standalone image lacks wget/curl, so we can't run an + # in-container healthcheck. Wait externally: + # until curl -sf http://127.0.0.1:8080/api/service/readiness; do sleep 1; done diff --git a/tests/integration/imap_test.go b/tests/integration/imap_test.go new file mode 100644 index 0000000000000000000000000000000000000000..353e29af889c98f8da93c0e767c4c0613d971dac --- /dev/null +++ b/tests/integration/imap_test.go @@ -0,0 +1,267 @@ +//go:build integration + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/smtp" + "net/textproto" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/floatpane/matcha/backend" + _ "github.com/floatpane/matcha/backend/imap" + "github.com/floatpane/matcha/config" +) + +// testEnv resolves the integration test environment. Greenmail exposes the +// following ports by default — we read them from env to allow remapping: +// +// MATCHA_TEST_IMAP_HOST default: 127.0.0.1 +// MATCHA_TEST_IMAP_PORT default: 3993 (implicit TLS) +// MATCHA_TEST_SMTP_PORT default: 3465 (implicit TLS, used by matcha sender) +// MATCHA_TEST_SMTP_PLAIN_PORT default: 3025 (plain SMTP, used by deliverViaSMTP) +// MATCHA_TEST_API_PORT default: 8080 (Greenmail REST API) +type testEnv struct { + host string + imapPort int + smtpPort int + smtpPlainPort int + apiPort int +} + +func loadEnv(t *testing.T) testEnv { + t.Helper() + env := testEnv{ + host: getenv("MATCHA_TEST_IMAP_HOST", "127.0.0.1"), + imapPort: getenvInt(t, "MATCHA_TEST_IMAP_PORT", 3993), + smtpPort: getenvInt(t, "MATCHA_TEST_SMTP_PORT", 3465), + smtpPlainPort: getenvInt(t, "MATCHA_TEST_SMTP_PLAIN_PORT", 3025), + apiPort: getenvInt(t, "MATCHA_TEST_API_PORT", 8080), + } + return env +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getenvInt(t *testing.T, key string, fallback int) int { + t.Helper() + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + t.Fatalf("invalid %s: %v", key, err) + } + return n +} + +func waitForGreenmail(t *testing.T, env testEnv) { + t.Helper() + deadline := time.Now().Add(60 * time.Second) + url := fmt.Sprintf("http://%s:%d/api/configuration", env.host, env.apiPort) + for time.Now().Before(deadline) { + resp, err := http.Get(url) //nolint:gosec + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + return + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("greenmail not ready after 60s at %s", url) +} + +func resetGreenmail(t *testing.T, env testEnv) { + t.Helper() + url := fmt.Sprintf("http://%s:%d/api/mail/purge", env.host, env.apiPort) + req, _ := http.NewRequest(http.MethodPost, url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("reset greenmail: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + t.Fatalf("reset greenmail: status %d", resp.StatusCode) + } +} + +// deliverViaSMTP injects a message into the IMAP store by speaking SMTP to +// Greenmail directly. Greenmail's REST API only supports reading and purging; +// SMTP is the only documented way to inject mail. +func deliverViaSMTP(t *testing.T, env testEnv, from, to, subject, body string) { + t.Helper() + addr := fmt.Sprintf("%s:%d", env.host, env.smtpPlainPort) + + hdr := textproto.MIMEHeader{} + hdr.Set("From", from) + hdr.Set("To", to) + hdr.Set("Subject", subject) + hdr.Set("Date", time.Now().UTC().Format(time.RFC1123Z)) + hdr.Set("MIME-Version", "1.0") + hdr.Set("Content-Type", "text/plain; charset=UTF-8") + + var msg strings.Builder + for k, vs := range hdr { + for _, v := range vs { + fmt.Fprintf(&msg, "%s: %s\r\n", k, v) + } + } + msg.WriteString("\r\n") + msg.WriteString(body) + + if err := smtp.SendMail(addr, nil, from, []string{to}, []byte(msg.String())); err != nil { + t.Fatalf("deliver via smtp: %v", err) + } + // Greenmail delivers asynchronously; wait briefly so the next IMAP read + // sees the message. + time.Sleep(300 * time.Millisecond) +} + +func newTestAccount(env testEnv, user, pass string) *config.Account { + return &config.Account{ + ID: "test-account", + Name: "Test User", + Email: user, + Password: pass, + ServiceProvider: "custom", + IMAPServer: env.host, + IMAPPort: env.imapPort, + SMTPServer: env.host, + SMTPPort: env.smtpPort, + Insecure: true, + Protocol: "imap", + SC: &config.SessionCache{}, + } +} + +func TestIntegration_FetchInbox(t *testing.T) { + env := loadEnv(t) + waitForGreenmail(t, env) + resetGreenmail(t, env) + + const user = "alice@example.com" + const pass = "secret" + + deliverViaSMTP(t, env, "bob@example.com", user, "Hello Alice", "first message") + deliverViaSMTP(t, env, "carol@example.com", user, "Invoice Q4", "please pay") + + acct := newTestAccount(env, user, pass) + provider, err := backend.New(acct) + if err != nil { + t.Fatalf("backend.New: %v", err) + } + defer provider.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + emails, err := provider.FetchEmails(ctx, "INBOX", 50, 0) + if err != nil { + t.Fatalf("FetchEmails: %v", err) + } + if len(emails) != 2 { + t.Fatalf("FetchEmails returned %d, want 2", len(emails)) + } + + subjects := map[string]bool{} + for _, e := range emails { + subjects[e.Subject] = true + } + for _, want := range []string{"Hello Alice", "Invoice Q4"} { + if !subjects[want] { + t.Errorf("missing subject %q in %v", want, subjects) + } + } +} + +func TestIntegration_SearchSubject(t *testing.T) { + env := loadEnv(t) + waitForGreenmail(t, env) + resetGreenmail(t, env) + + const user = "alice@example.com" + const pass = "secret" + + deliverViaSMTP(t, env, "bob@example.com", user, "Invoice Q4", "") + deliverViaSMTP(t, env, "bob@example.com", user, "Random update", "") + deliverViaSMTP(t, env, "bob@example.com", user, "Invoice Q1", "") + + acct := newTestAccount(env, user, pass) + provider, err := backend.New(acct) + if err != nil { + t.Fatalf("backend.New: %v", err) + } + defer provider.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + results, err := provider.Search(ctx, "INBOX", backend.SearchQuery{Subject: "Invoice"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 2 { + t.Fatalf("Search returned %d, want 2", len(results)) + } + for _, r := range results { + if !strings.Contains(r.Subject, "Invoice") { + t.Errorf("unexpected subject %q in search results", r.Subject) + } + } +} + +func TestIntegration_MarkAsRead(t *testing.T) { + env := loadEnv(t) + waitForGreenmail(t, env) + resetGreenmail(t, env) + + const user = "alice@example.com" + const pass = "secret" + + deliverViaSMTP(t, env, "bob@example.com", user, "Toggle me", "") + + acct := newTestAccount(env, user, pass) + provider, err := backend.New(acct) + if err != nil { + t.Fatalf("backend.New: %v", err) + } + defer provider.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + emails, err := provider.FetchEmails(ctx, "INBOX", 10, 0) + if err != nil || len(emails) != 1 { + t.Fatalf("FetchEmails: err=%v len=%d", err, len(emails)) + } + uid := emails[0].UID + if emails[0].IsRead { + t.Fatal("email unexpectedly marked read before MarkAsRead") + } + + if err := provider.MarkAsRead(ctx, "INBOX", uid); err != nil { + t.Fatalf("MarkAsRead: %v", err) + } + + emails, err = provider.FetchEmails(ctx, "INBOX", 10, 0) + if err != nil { + t.Fatalf("re-fetch: %v", err) + } + if !emails[0].IsRead { + t.Error("email not marked read after MarkAsRead") + } +} diff --git a/tui/render_bench_test.go b/tui/render_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..aa24fc12a57d0501be043bd4a094309494ae9f9d --- /dev/null +++ b/tui/render_bench_test.go @@ -0,0 +1,62 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" +) + +func BenchmarkLogPanelView(b *testing.B) { + logger := &snapshotLogger{} + for i := 0; i < 10; i++ { + logger.Write([]byte("benchmark log line " + strings.Repeat("x", 40) + "\n")) + } + panel := NewLogPanel(logger) + panel.SetSize(80, 12) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = panel.View() + } +} + +func BenchmarkSearchOverlayView(b *testing.B) { + overlay := NewSearchOverlay(80, 24) + emails := make([]fetcher.Email, 10) + for i := range emails { + emails[i] = fetcher.Email{ + UID: uint32(i), + From: "sender@example.com", + Subject: "Benchmark email subject", + Date: time.Now(), + } + } + overlay.results = emails + overlay.done = true + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = overlay.View() + } +} + +func BenchmarkInboxConstruction(b *testing.B) { + accounts := []config.Account{{ID: "a", Email: "a@example.com"}} + emails := make([]fetcher.Email, 500) + for i := range emails { + emails[i] = fetcher.Email{ + UID: uint32(i), + From: "bench@example.com", + Subject: "Subject line " + strings.Repeat("y", 20), + Date: time.Now().Add(-time.Duration(i) * time.Minute), + AccountID: "a", + } + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = NewInbox(emails, accounts) + } +} diff --git a/tui/snapshot_test.go b/tui/snapshot_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3719f65b355990d559d9bd9fbf5cd16ef9961a98 --- /dev/null +++ b/tui/snapshot_test.go @@ -0,0 +1,128 @@ +package tui + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/floatpane/matcha/internal/logging" +) + +var updateGolden = flag.Bool("update", false, "update golden snapshot files") + +// snapshotLogger is a deterministic in-memory logger for snapshot tests. +type snapshotLogger struct { + entries []logging.Entry +} + +func (l *snapshotLogger) Write(p []byte) (int, error) { + l.entries = append(l.entries, logging.Entry{Text: strings.TrimRight(string(p), "\n")}) + return len(p), nil +} +func (l *snapshotLogger) MaxEntries() int { return logging.DefaultMaxEntries } +func (l *snapshotLogger) Tail(n int) []logging.Entry { + if n <= 0 || len(l.entries) == 0 { + return nil + } + if n >= len(l.entries) { + out := make([]logging.Entry, len(l.entries)) + copy(out, l.entries) + return out + } + out := make([]logging.Entry, n) + copy(out, l.entries[len(l.entries)-n:]) + return out +} +func (l *snapshotLogger) Subscribe() <-chan logging.Entry { return nil } + +// assertGolden compares rendered output to a golden file in testdata/golden. +// Re-run tests with `-update` to refresh the golden files. +func assertGolden(t *testing.T, name, got string) { + t.Helper() + + got = normalizeForGolden(got) + + path := filepath.Join("testdata", "golden", name+".txt") + + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir golden: %v", err) + } + if err := os.WriteFile(path, []byte(got+"\n"), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + return + } + + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden %q (run with -update to create): %v", path, err) + } + wantStr := normalizeForGolden(string(want)) + if got != wantStr { + t.Fatalf("snapshot mismatch for %s\n--- got ---\n%s\n--- want ---\n%s\n--- diff ---\ngot bytes: %q\nwant bytes: %q", + name, got, wantStr, got, wantStr) + } +} + +func normalizeForGolden(s string) string { + s = ansi.Strip(s) + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = stripTrailingSpace(s) + return strings.TrimRight(s, " \n\t") +} + +func stripTrailingSpace(s string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + return strings.Join(lines, "\n") +} + +func TestSnapshot_LogPanel_Empty(t *testing.T) { + panel := NewLogPanel(&snapshotLogger{}) + panel.SetSize(60, 6) + assertGolden(t, "log_panel_empty", panel.View()) +} + +func TestSnapshot_LogPanel_WithEntries(t *testing.T) { + logger := &snapshotLogger{} + logger.Write([]byte("started fetcher\n")) + logger.Write([]byte("connected to imap.example.com\n")) + logger.Write([]byte("fetched 12 new messages\n")) + + panel := NewLogPanel(logger) + panel.SetSize(60, 6) + assertGolden(t, "log_panel_with_entries", panel.View()) +} + +func TestSnapshot_LogPanel_TruncatesLongLines(t *testing.T) { + logger := &snapshotLogger{} + logger.Write([]byte(strings.Repeat("verylongline ", 20) + "\n")) + + panel := NewLogPanel(logger) + panel.SetSize(30, 4) + assertGolden(t, "log_panel_truncated", panel.View()) +} + +func TestSnapshot_SearchOverlay_Empty(t *testing.T) { + overlay := NewSearchOverlay(80, 24) + assertGolden(t, "search_overlay_empty", overlay.View()) +} + +func TestSnapshot_SearchOverlay_Loading(t *testing.T) { + overlay := NewSearchOverlay(80, 24) + overlay.loading = true + assertGolden(t, "search_overlay_loading", overlay.View()) +} + +func TestSnapshot_SearchOverlay_Error(t *testing.T) { + overlay := NewSearchOverlay(80, 24) + overlay.err = "connection refused" + assertGolden(t, "search_overlay_error", overlay.View()) +} diff --git a/tui/testdata/golden/log_panel_empty.txt b/tui/testdata/golden/log_panel_empty.txt new file mode 100644 index 0000000000000000000000000000000000000000..12c982afba89bc4c1852f53024d54261e9a89f87 --- /dev/null +++ b/tui/testdata/golden/log_panel_empty.txt @@ -0,0 +1,3 @@ +──────────────────────────────────────────────────────────── +[Logs] +No logs yet diff --git a/tui/testdata/golden/log_panel_truncated.txt b/tui/testdata/golden/log_panel_truncated.txt new file mode 100644 index 0000000000000000000000000000000000000000..8e66c42abc14316ca8fa282e2fb5e384bde9fe07 --- /dev/null +++ b/tui/testdata/golden/log_panel_truncated.txt @@ -0,0 +1,3 @@ +────────────────────────────── +[Logs] +verylongline verylongline ver… diff --git a/tui/testdata/golden/log_panel_with_entries.txt b/tui/testdata/golden/log_panel_with_entries.txt new file mode 100644 index 0000000000000000000000000000000000000000..6cf362226dc1cd311cd5a652e678f34a78022c1e --- /dev/null +++ b/tui/testdata/golden/log_panel_with_entries.txt @@ -0,0 +1,5 @@ +──────────────────────────────────────────────────────────── +[Logs] +started fetcher +connected to imap.example.com +fetched 12 new messages diff --git a/tui/testdata/golden/search_overlay_empty.txt b/tui/testdata/golden/search_overlay_empty.txt new file mode 100644 index 0000000000000000000000000000000000000000..7d7cb94f4ddabf122d23990e5c407ed2ca2a441a --- /dev/null +++ b/tui/testdata/golden/search_overlay_empty.txt @@ -0,0 +1,7 @@ +╭──────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Search mail │ +│ │ +│ / f │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/testdata/golden/search_overlay_error.txt b/tui/testdata/golden/search_overlay_error.txt new file mode 100644 index 0000000000000000000000000000000000000000..94fc2e9e3f2649d316012588e3620bc63159c03c --- /dev/null +++ b/tui/testdata/golden/search_overlay_error.txt @@ -0,0 +1,9 @@ +╭──────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Search mail │ +│ │ +│ / f │ +│ │ +│ connection refused │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/testdata/golden/search_overlay_loading.txt b/tui/testdata/golden/search_overlay_loading.txt new file mode 100644 index 0000000000000000000000000000000000000000..3417771bc007fa533270a3e26b7cbfe5707d4aa7 --- /dev/null +++ b/tui/testdata/golden/search_overlay_loading.txt @@ -0,0 +1,9 @@ +╭──────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Search mail │ +│ │ +│ / f │ +│ │ +│ Searching... │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯