Repository Public
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { execFileSync } from 'node:child_process';3import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';4import path from 'node:path';5import type { FastifyInstance } from 'fastify';6import type { GitStatus } from '@cr/shared';7import { buildApp, call, errCode, makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';8import { createGitPlugin, parseStatusV2, sanitizeRemote, validateCloneUrl } from './git.js';910let tmp: string;11let ws: string;12let repo: string;13let plain: string;14let bare: string;15let outside: string;16let app: FastifyInstance;17let appAnyUrl: FastifyInstance; // test-only validator that also accepts local bare repos1819const GIT_ENV = {20 ...process.env,21 GIT_CONFIG_GLOBAL: '/dev/null',22 GIT_CONFIG_SYSTEM: '/dev/null',23 GIT_AUTHOR_NAME: 'T',24 GIT_AUTHOR_EMAIL: 't@example.invalid',25 GIT_COMMITTER_NAME: 'T',26 GIT_COMMITTER_EMAIL: 't@example.invalid',27};28const git = (cwd: string, ...args: string[]): string => execFileSync('git', args, { cwd, env: GIT_ENV, encoding: 'utf8' });2930beforeAll(async () => {31 tmp = makeTmp('cr-git-');32 ws = path.join(tmp, 'ws');33 repo = path.join(ws, 'repo');34 plain = path.join(ws, 'plain');35 bare = path.join(tmp, 'origin.git');36 outside = path.join(tmp, 'outside');37 mkdirSync(repo, { recursive: true });38 mkdirSync(plain, { recursive: true });39 mkdirSync(outside, { recursive: true });40 git(repo, 'init', '-q', '-b', 'main');41 writeFileSync(path.join(repo, 'tracked.txt'), 'one\n');42 writeFileSync(path.join(repo, 'to rename.txt'), 'rename me, with enough content to be detected as a rename\n');43 writeFileSync(path.join(repo, 'gone.txt'), 'bye\n');44 git(repo, 'add', '-A');45 git(repo, 'commit', '-q', '-m', 'init');46 git(tmp, 'clone', '-q', '--bare', repo, bare);47 git(repo, 'remote', 'add', 'origin', 'https://user:s3cr3t-token@example.invalid/team/repo.git');48 const config = makeConfig([ws]);49 app = await buildApp(config, createGitPlugin());50 appAnyUrl = await buildApp(config, createGitPlugin({ validateUrl: (u) => (u === bare || u.endsWith('missing.git') ? null : validateCloneUrl(u)) }));51});52afterAll(async () => {53 await app.close();54 await appAnyUrl.close();55 rmTmp(tmp);56});5758describe('parseStatusV2', () => {59 it('parses headers, ordinary, renamed, unmerged, untracked and ignored records', () => {60 const out = [61 '# branch.oid 1234',62 '# branch.head feature/x',63 '# branch.upstream origin/feature/x',64 '# branch.ab +3 -2',65 '1 .M N... 100644 100644 100644 aaa bbb src/a b.ts',66 '1 A. N... 000000 100644 100644 000 ccc new.ts',67 '2 R. N... 100644 100644 100644 ddd eee R100 new name.txt',68 'old name.txt',69 'u UU N... 100644 100644 100644 100644 f1 f2 f3 conflict.txt',70 '? untracked dir/',71 '! ignored.log',72 '',73 ].join('\0');74 expect(parseStatusV2(out)).toEqual({75 isRepo: true,76 branch: 'feature/x',77 ahead: 3,78 behind: 2,79 files: [80 { path: 'src/a b.ts', index: '.', worktree: 'M' },81 { path: 'new.ts', index: 'A', worktree: '.' },82 { path: 'new name.txt', index: 'R', worktree: '.' },83 { path: 'conflict.txt', index: 'U', worktree: 'U' },84 { path: 'untracked dir/', index: '?', worktree: '?' },85 { path: 'ignored.log', index: '!', worktree: '!' },86 ],87 });88 expect(parseStatusV2('# branch.oid (initial)\0# branch.head (detached)\0').branch).toBeNull();89 });90});9192describe('/git/info + /git/status + /git/diff', () => {93 it('reports a clean repo, with the remote stripped of credentials', async () => {94 const info = await call<{ isRepo: boolean; branch: string; remote: string; dirty: boolean }>(app, 'POST', '/git/info', { root: repo });95 expect(info.body).toEqual({ isRepo: true, branch: 'main', remote: 'https://example.invalid/team/repo.git', dirty: false });96 expect(JSON.stringify(info.body)).not.toContain('s3cr3t');97 const st = await call<GitStatus>(app, 'POST', '/git/status', { root: repo });98 expect(st.body).toEqual({ isRepo: true, branch: 'main', ahead: 0, behind: 0, files: [] });99 });100 it('reports modified, staged, renamed, deleted and untracked files', async () => {101 writeFileSync(path.join(repo, 'tracked.txt'), 'one\ntwo\n');102 writeFileSync(path.join(repo, 'staged.txt'), 'staged\n');103 git(repo, 'add', 'staged.txt');104 git(repo, 'mv', 'to rename.txt', 'was renamed.txt');105 rmSync(path.join(repo, 'gone.txt'));106 writeFileSync(path.join(repo, 'untracked file.txt'), 'u\n');107 const st = await call<GitStatus>(app, 'POST', '/git/status', { root: repo });108 const byPath = Object.fromEntries(st.body.files.map((f) => [f.path, `${f.index}${f.worktree}`]));109 expect(byPath).toEqual({110 'tracked.txt': '.M',111 'staged.txt': 'A.',112 'was renamed.txt': 'R.',113 'gone.txt': '.D',114 'untracked file.txt': '??',115 });116 expect((await call<{ dirty: boolean }>(app, 'POST', '/git/info', { root: repo })).body.dirty).toBe(true);117 });118 it('diffs the worktree, the index, and a single validated path', async () => {119 const all = await call<{ diff: string; truncated: boolean }>(app, 'POST', '/git/diff', { root: repo });120 expect(all.body.diff).toContain('+two');121 expect(all.body.diff).not.toContain('+staged');122 expect(all.body.truncated).toBe(false);123 const staged = await call<{ diff: string }>(app, 'POST', '/git/diff', { root: repo, staged: true });124 expect(staged.body.diff).toContain('+staged');125 expect(staged.body.diff).not.toContain('+two');126 const one = await call<{ diff: string }>(app, 'POST', '/git/diff', { root: repo, rel: 'gone.txt' });127 expect(one.body.diff).toContain('-bye');128 expect(one.body.diff).not.toContain('+two');129 expect(errCode(await call(app, 'POST', '/git/diff', { root: repo, rel: '../plain' }))).toBe('bad_path');130 // pathspec magic and options are literals, not instructions131 expect((await call<{ diff: string }>(app, 'POST', '/git/diff', { root: repo, rel: ':(glob)**' })).body.diff).toBe('');132 expect((await call<{ diff: string }>(app, 'POST', '/git/diff', { root: repo, rel: '--output=pwned' })).body.diff).toBe('');133 expect(existsSync(path.join(repo, 'pwned'))).toBe(false);134 });135 it('caps the diff at 1 MiB and flags truncation', async () => {136 writeFileSync(path.join(repo, 'tracked.txt'), 'line of text that repeats\n'.repeat(60000));137 const r = await call<{ diff: string; truncated: boolean }>(app, 'POST', '/git/diff', { root: repo, rel: 'tracked.txt' });138 expect(r.status).toBe(200);139 expect(r.body.truncated).toBe(true);140 expect(Buffer.byteLength(r.body.diff)).toBe(1024 * 1024);141 writeFileSync(path.join(repo, 'tracked.txt'), 'one\ntwo\n');142 });143 it('never runs repository-controlled programs while inspecting', async () => {144 const marker = path.join(outside, 'pwned');145 const script = path.join(outside, 'evil.sh');146 writeFileSync(script, `#!/bin/sh\ntouch "${marker}"\nexit 0\n`, { mode: 0o755 });147 git(repo, 'config', 'core.fsmonitor', script);148 git(repo, 'config', 'diff.external', script);149 git(repo, 'config', 'diff.evil.textconv', script);150 git(repo, 'config', 'core.hooksPath', outside);151 writeFileSync(path.join(repo, '.gitattributes'), '*.txt diff=evil\n');152 try {153 expect((await call(app, 'POST', '/git/status', { root: repo })).status).toBe(200);154 expect((await call(app, 'POST', '/git/info', { root: repo })).status).toBe(200);155 const d = await call<{ diff: string }>(app, 'POST', '/git/diff', { root: repo });156 expect(d.body.diff).toContain('+two');157 expect(existsSync(marker)).toBe(false);158 // sanity: the trap is live — plain git (no protections) does trigger it159 git(repo, 'diff');160 expect(existsSync(marker)).toBe(true);161 } finally {162 for (const k of ['core.fsmonitor', 'diff.external', 'diff.evil.textconv', 'core.hooksPath']) git(repo, 'config', '--unset', k);163 rmSync(path.join(repo, '.gitattributes'));164 }165 });166 it('treats non-repositories and repositories rooted above the project as "not a repo"', async () => {167 expect((await call(app, 'POST', '/git/info', { root: plain })).body).toEqual({ isRepo: false, branch: null, remote: null, dirty: null });168 expect((await call<GitStatus>(app, 'POST', '/git/status', { root: plain })).body.isRepo).toBe(false);169 expect(errCode(await call(app, 'POST', '/git/diff', { root: plain }))).toBe('not_a_repo');170 mkdirSync(path.join(repo, 'subproject'), { recursive: true });171 expect((await call<GitStatus>(app, 'POST', '/git/status', { root: path.join(repo, 'subproject') })).body.isRepo).toBe(false);172 expect(errCode(await call(app, 'POST', '/git/status', { root: outside }))).toBe('root_not_allowed');173 expect((await call(app, 'POST', '/git/status', {})).status).toBe(400);174 });175 it('ignores inherited GIT_DIR', async () => {176 process.env.GIT_DIR = path.join(repo, '.git');177 try {178 expect((await call<GitStatus>(app, 'POST', '/git/status', { root: plain })).body.isRepo).toBe(false);179 } finally {180 delete process.env.GIT_DIR;181 }182 });183});184185describe('clone URL whitelist', () => {186 it.each([187 'https://github.com/org/repo.git',188 'https://git.spboucher.ai/x/y',189 'ssh://git@github.com/org/repo.git',190 'ssh://git@host.example:2222/srv/git/x.git',191 'git@github.com:org/repo.git',192 'gitsrv:srv/git/claude-remote.git',193 ])('accepts %s', (url) => expect(validateCloneUrl(url)).toBeNull());194 it.each([195 '',196 '-u',197 '--upload-pack=touch /tmp/pwned',198 'file:///etc',199 '/abs/local/path',200 '../relative',201 'http://insecure.example/x.git',202 'git://example.com/x.git',203 'ext::sh -c touch% /tmp/pwned',204 'ext::sh',205 'https://example.com/x y.git',206 'https://example.com/x\n.git',207 'https://example.com/x\t.git',208 'https://example.com/x\u0000.git',209 'https://',210 'https://example.com',211 'ssh://-oProxyCommand=touch${IFS}pwned/x',212 'ssh://-oProxyCommand=x@host/x',213 'ssh://git@-oProxyCommand=x/x',214 'git@-oProxyCommand=x:repo',215 'git@host:-repo',216 'git@host:repo;rm -rf',217 'gitsrv:',218 'gitsrv:-x',219 'gitsrv:a b',220 'other:path',221 'user@host:path',222 'HTTPS://x'.padEnd(600, 'a'),223 ])('rejects %j', (url) => expect(validateCloneUrl(url)).not.toBeNull());224 it('sanitizes remotes', () => {225 expect(sanitizeRemote('https://u:p@h.example/a/b.git?x=1')).toBe('https://h.example/a/b.git');226 expect(sanitizeRemote('git@github.com:org/repo.git')).toBe('git@github.com:org/repo.git');227 });228});229230describe('/git/clone', () => {231 it('rejects non-whitelisted URLs before running anything', async () => {232 for (const url of [`file://${bare}`, bare, '--upload-pack=x', 'ext::sh -c id']) {233 const r = await call(app, 'POST', '/git/clone', { root: ws, rel: 'cloned-bad', url });234 expect([r.status, errCode(r)]).toEqual([400, 'bad_url']);235 }236 expect(existsSync(path.join(ws, 'cloned-bad'))).toBe(false);237 });238 it('clones into a validated, non-existing destination (test-only validator)', async () => {239 const r = await call<{ ok: boolean; path: string }>(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: 'nested/cloned', url: bare });240 expect(r.status).toBe(200);241 expect(r.body).toEqual({ ok: true, path: 'nested/cloned' });242 expect(readFileSync(path.join(ws, 'nested', 'cloned', 'tracked.txt'), 'utf8')).toBe('one\n');243 const again = await call(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: 'nested/cloned', url: bare });244 expect([again.status, errCode(again)]).toEqual([409, 'exists']);245 expect(errCode(await call(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: '', url: bare }))).toBe('bad_request');246 expect(errCode(await call(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: '.', url: bare }))).toBe('exists');247 });248 it('refuses destinations escaping the root', async () => {249 symlinkSync(outside, path.join(ws, 'out-link'));250 symlinkSync(path.join(outside, 'new-clone'), path.join(ws, 'dangling'));251 expect(errCode(await call(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: '../escape', url: bare }))).toBe('bad_path');252 expect(errCode(await call(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: 'out-link/c', url: bare }))).toBe('path_outside_root');253 expect(errCode(await call(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: 'dangling', url: bare }))).toBe('path_outside_root');254 expect(errCode(await call(appAnyUrl, 'POST', '/git/clone', { root: outside, rel: 'c', url: bare }))).toBe('root_not_allowed');255 expect(existsSync(path.join(outside, 'c'))).toBe(false);256 expect(existsSync(path.join(outside, 'new-clone'))).toBe(false);257 });258 it('removes the partial directory when the clone fails', async () => {259 const missing = path.join(tmp, 'missing.git');260 const r = await call<{ error: { code: string; message: string } }>(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: 'failed-clone', url: missing });261 expect([r.status, r.body.error.code]).toEqual([502, 'clone_failed']);262 expect(existsSync(path.join(ws, 'failed-clone'))).toBe(false);263 // a clone interrupted half-way must not leave the directory either264 renameSync(path.join(bare, 'objects'), path.join(bare, 'objects.off'));265 try {266 const broken = await call(appAnyUrl, 'POST', '/git/clone', { root: ws, rel: 'broken-clone', url: bare });267 expect(broken.status).toBe(502);268 expect(existsSync(path.join(ws, 'broken-clone'))).toBe(false);269 } finally {270 renameSync(path.join(bare, 'objects.off'), path.join(bare, 'objects'));271 }272 });273});274