feat(diff): diff a whole branch vs a base (?base=<rev>) — review before landing (W3)
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.
- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
attacker-supplied base never reaches a shell or acts as a git option:
(1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
<base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
(3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
disables Working/Staged tabs in base mode. public/projects.ts derives bases from
the worktree branches ∪ current branch.
Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
This commit is contained in:
@@ -87,6 +87,28 @@ async function makeRealRepo(): Promise<string> {
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Temp git repo on `main` with a `feature` branch that adds one committed file
|
||||
* containing a would-be XSS payload. Returns its absolute path. */
|
||||
async function makeTwoBranchRepo(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-base-'))
|
||||
tmpDirs.push(dir)
|
||||
const git = (args: string[]): void => {
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
|
||||
}
|
||||
git(['init', '-b', 'main'])
|
||||
git(['config', 'user.email', 'test@localhost'])
|
||||
git(['config', 'user.name', 'Test'])
|
||||
git(['config', 'commit.gpgsign', 'false'])
|
||||
await fs.writeFile(path.join(dir, 'base.txt'), 'base\n', 'utf8')
|
||||
git(['add', '.'])
|
||||
git(['commit', '-m', 'base on main'])
|
||||
git(['checkout', '-b', 'feature'])
|
||||
await fs.writeFile(path.join(dir, 'feat.txt'), '<script>x</script>\n', 'utf8')
|
||||
git(['add', '.'])
|
||||
git(['commit', '-m', 'feature work'])
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (handles.length > 0) await handles.pop()?.close()
|
||||
for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined)
|
||||
@@ -200,4 +222,54 @@ describe('GET /projects/diff (B1)', () => {
|
||||
// The diff carries the raw text verbatim (the FE renders it inert via textContent).
|
||||
expect(flat).toContain('<script>x</script>')
|
||||
})
|
||||
|
||||
// ── FR-B1.9: ?base=<rev> whole-branch diff ─────────────────────────────────
|
||||
|
||||
itGit('diffs the whole branch against a base (?base=main), echoing base', async () => {
|
||||
const repo = await makeTwoBranchRepo() // HEAD is on `feature`
|
||||
const { port } = await spawnServer()
|
||||
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=main`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const diff = (await res.json()) as DiffResult
|
||||
expect(diff.base).toBe('main')
|
||||
expect(diff.staged).toBe(false)
|
||||
const feat = diff.files.find((f) => f.newPath === 'feat.txt')
|
||||
expect(feat).toBeDefined()
|
||||
expect(feat?.status).toBe('added')
|
||||
expect(JSON.stringify(diff)).toContain('<script>x</script>')
|
||||
})
|
||||
|
||||
itGit('rejects a flag-injection base with 400 (?base=-rf)', async () => {
|
||||
const repo = await makeTwoBranchRepo()
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=-rf`,
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('rejects a metacharacter base with 400 (?base=main;rm)', async () => {
|
||||
const repo = await makeTwoBranchRepo()
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}` +
|
||||
`&base=${encodeURIComponent('main; rm -rf /')}`,
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('returns 200 with an empty diff for an unknown base (?base=ghost-branch)', async () => {
|
||||
const repo = await makeTwoBranchRepo()
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=ghost-branch`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const diff = (await res.json()) as DiffResult
|
||||
expect(diff.base).toBe('ghost-branch')
|
||||
expect(diff.files).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user