// Unit: $SSH_ORIGINAL_COMMAND parser/sanitizer (classic-git-auth Phase 2).
// Security-critical — it's the only thing between an SSH session and exec.
import test from 'brittle'
import { parseGitCommand, isWriteOp, safeRepoPath } from '../lib/auth/git-shell-parse.js'
import { resolve, sep } from '#node:path'
test('parseGitCommand: valid push/fetch commands', (t) => {
t.alike(parseGitCommand("git-receive-pack 'electrical-review.git'"), { ok: true, op: 'receive-pack', repo: 'electrical-review' })
t.alike(parseGitCommand("git-upload-pack '/foo.git'"), { ok: true, op: 'upload-pack', repo: 'foo' })
t.alike(parseGitCommand("git-upload-pack '~/bar.git'"), { ok: true, op: 'upload-pack', repo: 'bar' })
t.alike(parseGitCommand('git receive-pack "baz.git"'), { ok: true, op: 'receive-pack', repo: 'baz' }, 'space spelling + double quotes')
})
test('parseGitCommand: allowlist is strictly the two pack commands (#74 Phase 4)', (t) => {
// git-upload-archive was dropped from the allowlist to shrink the exec surface.
t.absent(parseGitCommand("git-upload-archive 'q.git'").ok, 'upload-archive no longer permitted')
t.absent(parseGitCommand("git upload-archive 'q.git'").ok, 'space spelling also rejected')
})
test('parseGitCommand: refuses non-git / interactive', (t) => {
t.absent(parseGitCommand('').ok, 'empty (interactive shell)')
t.absent(parseGitCommand('rm -rf /').ok, 'arbitrary command')
t.absent(parseGitCommand('bash').ok, 'shell')
t.absent(parseGitCommand('git-receive-pack').ok, 'no repo arg')
t.absent(parseGitCommand("scp -t '/x'").ok, 'scp')
})
test('parseGitCommand: blocks traversal / subpaths / injection', (t) => {
t.absent(parseGitCommand("git-receive-pack '../../etc/passwd'").ok, 'traversal')
t.absent(parseGitCommand("git-upload-pack 'a/b.git'").ok, 'subdir path separator')
t.absent(parseGitCommand("git-receive-pack 'foo.git; rm -rf /'").ok, 'shell metachars rejected by charset')
t.absent(parseGitCommand("git-receive-pack 'foo; ls'").ok, 'space + semicolon rejected (no slash needed)')
t.absent(parseGitCommand("git-receive-pack 'foo\nbar'").ok, 'newline in repo rejected')
})
test('parseGitCommand: repo name normalization is exact', (t) => {
// surrounding quotes stripped once; .git + leading slash/~ removed; nothing else
t.is(parseGitCommand("git-receive-pack 'my-repo.git'").repo, 'my-repo')
t.is(parseGitCommand("git-receive-pack 'my-repo'").repo, 'my-repo', 'no .git suffix is fine')
})
test('clone-URL contract: scp short form accepted, ssh:// absolute path rejected (#74 Phase 2)', (t) => {
// The repo page renders the scp SHORT form `user@host:repo.git`; git sends
// `git-upload-pack 'repo.git'`. The wrapper resolves the bare NAME against
// its own reposDir — so the absolute-path form a naive ssh:// URL produces
// (`git-upload-pack '/home/fleeky/git-repos/repo.git'`) is REJECTED by the
// single-segment charset gate. This pins the two forms apart so the page
// can't regress back to the rejected URL.
t.alike(parseGitCommand("git-upload-pack 'pear-git-v3.git'"),
{ ok: true, op: 'upload-pack', repo: 'pear-git-v3' }, 'scp short form (what the page emits) → resolves to the bare name')
t.absent(parseGitCommand("git-upload-pack '/home/fleeky/git-repos/pear-git-v3.git'").ok,
'ssh:// absolute path (the OLD page URL) is rejected — multi-segment')
t.absent(parseGitCommand("git-receive-pack '~/git-repos/pear-git-v3.git'").ok,
'multi-segment tilde path also rejected')
})
test('isWriteOp', (t) => {
t.ok(isWriteOp('receive-pack'), 'push is a write')
t.absent(isWriteOp('upload-pack'), 'fetch is not')
})
test('safeRepoPath: contains the resolved path inside reposDir (#74 Phase 4)', (t) => {
const base = resolve('/srv/git-repos')
t.is(safeRepoPath('/srv/git-repos', 'apds'), base + sep + 'apds.git', 'valid name → contained absolute path')
t.is(safeRepoPath('/srv/git-repos/', 'apds'), base + sep + 'apds.git', 'trailing slash on reposDir normalized')
// Anything the parser would already reject is rejected here too (belt + suspenders).
t.is(safeRepoPath('/srv/git-repos', '../etc/passwd'), null, 'traversal name rejected')
t.is(safeRepoPath('/srv/git-repos', 'a/b'), null, 'path separator rejected')
t.is(safeRepoPath('/srv/git-repos', '..'), null, 'parent ref rejected')
t.is(safeRepoPath('/srv/git-repos', ''), null, 'empty name rejected')
t.is(safeRepoPath('', 'apds'), null, 'empty reposDir rejected')
t.is(safeRepoPath('/srv/git-repos', 'foo bar'), null, 'space rejected')
})