/**
* Parse + sanitize the git command a forced-command SSH session is allowed to
* run (classic-git-auth Phase 2). The pgv3-git-shell wrapper receives the
* client's request in $SSH_ORIGINAL_COMMAND; this turns it into a validated
* { op, repo } or an error, refusing anything that isn't a git pack command.
*
* Pure + side-effect-free so it unit-tests without sshd. The wrapper stays a
* thin shell around this + a UDS ACL check + exec of the real git binary.
*/
import { resolve, sep } from '#node:path'
// Strict allowlist — ONLY the two git pack commands (#74 Phase 4). This is the
// entire exec surface of the same-account forced command, so it stays minimal.
// git-upload-archive (`git archive --remote`) was deliberately DROPPED: it's an
// extra server-side exec rarely used here (clone + pgh/punch:// are the norm).
// Re-add a line here if a remote `git archive` workflow is ever needed.
const GIT_OPS = {
'git-upload-pack': 'upload-pack', // clone / fetch (read)
'git-receive-pack': 'receive-pack' // push (write)
}
/**
* @param {string} cmd - raw $SSH_ORIGINAL_COMMAND
* @returns {{ ok: true, op: string, repo: string } | { ok: false, error: string }}
*/
export function parseGitCommand (cmd) {
if (typeof cmd !== 'string' || !cmd.trim()) {
return { ok: false, error: 'interactive shell access is disabled — git only' }
}
// Accept both "git-receive-pack" and "git receive-pack" spellings.
const s = cmd.trim().replace(/^git\s+(upload-pack|receive-pack)\b/, 'git-$1')
const sp = s.indexOf(' ')
if (sp === -1) return { ok: false, error: 'unsupported command: ' + clip(cmd) }
const op = s.slice(0, sp)
if (!GIT_OPS[op]) return { ok: false, error: 'only git fetch/push is permitted (got: ' + clip(op) + ')' }
let arg = s.slice(sp + 1).trim()
// strip ONE layer of surrounding single or double quotes
if (arg.length >= 2 &&
((arg[0] === "'" && arg[arg.length - 1] === "'") ||
(arg[0] === '"' && arg[arg.length - 1] === '"'))) {
arg = arg.slice(1, -1)
}
// Normalize a clone path to a bare repo NAME: drop ~/, leading slashes,
// and a trailing .git. Repos are flat (<name>.git); restrict to a safe
// charset and reject traversal — belt-and-suspenders even though the wrapper
// spawns git with an arg array (no shell), so the name is never interpreted.
const repo = arg.replace(/^~\//, '').replace(/^\/+/, '').replace(/\.git$/, '')
if (!repo || repo.includes('..') || !/^[A-Za-z0-9._-]+$/.test(repo)) {
return { ok: false, error: 'invalid repository path: ' + clip(arg) }
}
return { ok: true, op: GIT_OPS[op], repo }
}
/** Is this op a write (push)? The read op (upload-pack) stays open. */
export function isWriteOp (op) {
return op === 'receive-pack'
}
/**
* Resolve a validated repo NAME to its absolute bare-repo path, and PROVE the
* result is contained within reposDir (#74 Phase 4 — defense in depth).
*
* parseGitCommand already guarantees `repo` is a single safe segment (no `/`,
* no `..`), so traversal is impossible upstream — but re-validating here means
* a future loosening of the parser can't silently open a path-escape, and the
* wrapper never spawns git on a path it hasn't proven safe. Lexical resolve is
* sufficient: the client cannot create symlinks, so there is no realpath escape
* to chase. Returns the absolute path, or null if anything is off.
*
* @param {string} reposDir - daemon's bare-repos dir (baked into the forced cmd)
* @param {string} repo - bare repo name from parseGitCommand
* @returns {string|null} absolute path to `<reposDir>/<repo>.git`, or null
*/
export function safeRepoPath (reposDir, repo) {
if (typeof reposDir !== 'string' || !reposDir) return null
if (typeof repo !== 'string' || repo.includes('..') || !/^[A-Za-z0-9._-]+$/.test(repo)) return null
const base = resolve(reposDir)
const full = resolve(base, repo + '.git')
// Must be base itself (impossible here) or a child under base + separator.
if (full !== base && !full.startsWith(base + sep)) return null
return full
}
function clip (s) {
s = String(s)
return s.length > 40 ? s.slice(0, 40) + '…' : s
}