SPB Git

Repository Public

git.test.ts 13.3 KB 274 lines typescript

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
Files main
Menu

Theme