command-builder.ts•7.14 kB
/**
* @fileoverview Git CLI command builder utility
* @module services/git/providers/cli/utils/command-builder
*/
import type { AppConfig } from '@/config/index.js';
/**
* Git command configuration.
*/
export interface GitCommandConfig {
/** Base git command (e.g., 'status', 'commit', 'log') */
command: string;
/** Command arguments */
args?: string[];
/** Working directory for command execution */
cwd?: string;
/** Environment variables */
env?: Record<string, string>;
/** Timeout in milliseconds */
timeout?: number;
}
/**
* Build a git command with arguments.
*
* @param config - Command configuration
* @returns Array of command parts for execution
*
* @example
* ```typescript
* buildGitCommand({
* command: 'log',
* args: ['--pretty=format:%H', '--max-count=10'],
* })
* // Returns: ['log', '--pretty=format:%H', '--max-count=10']
* ```
*/
export function buildGitCommand(config: GitCommandConfig): string[] {
const parts: string[] = [config.command];
// Add positional arguments
if (config.args && config.args.length > 0) {
parts.push(...config.args);
}
return parts;
}
/**
* Escape a string for safe use in shell commands.
*
* @param str - String to escape
* @returns Escaped string
*/
export function escapeShellArg(str: string): string {
// Replace single quotes with '\'' and wrap in single quotes
return `'${str.replace(/'/g, "'\\''")}'`;
}
/**
* Helper to safely load config for git operations.
* Uses dynamic import to avoid circular dependencies.
*
* @returns AppConfig object or null if unavailable
*/
function loadConfig(): AppConfig | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configModule = require('@/config/index.js') as {
config: AppConfig;
};
return configModule.config;
} catch {
return null;
}
}
/**
* Build environment variables for git command.
*
* This function preserves the existing process environment (including PATH)
* to ensure git executable can be found, while adding git-specific settings.
*
* Automatically includes git author/committer information from config if available.
*
* @param additionalEnv - Additional environment variables to override defaults
* @returns Combined environment object with PATH preserved
*/
export function buildGitEnv(
additionalEnv?: Record<string, string>,
): Record<string, string> {
// Start with existing environment to preserve PATH and other critical vars
// This ensures git executable can be found in custom install locations
const env: Record<string, string> = { ...process.env } as Record<
string,
string
>;
// Override with git-specific settings
Object.assign(env, {
GIT_TERMINAL_PROMPT: '0', // Disable interactive prompts
LANG: 'en_US.UTF-8', // Ensure git uses UTF-8 encoding
LC_ALL: 'en_US.UTF-8',
});
// Load git author/committer info from config if available
// This allows consistent author identity across all git operations
const config = loadConfig();
if (config?.git) {
if (config.git.authorName) {
env.GIT_AUTHOR_NAME = config.git.authorName;
}
if (config.git.authorEmail) {
env.GIT_AUTHOR_EMAIL = config.git.authorEmail;
}
if (config.git.committerName) {
env.GIT_COMMITTER_NAME = config.git.committerName;
}
if (config.git.committerEmail) {
env.GIT_COMMITTER_EMAIL = config.git.committerEmail;
}
}
// Apply any additional overrides (highest priority)
if (additionalEnv) {
Object.assign(env, additionalEnv);
}
return env;
}
/**
* Known safe git options that are commonly used.
* This is a baseline set - expand as needed for your specific use cases.
*/
const SAFE_GIT_OPTIONS = new Set([
// Common flags
'--version',
'--help',
'--all',
'--force',
'--quiet',
'--verbose',
'-v',
'-f',
'-q',
// Status flags
'--porcelain',
'--porcelain=v2',
'-b',
'--untracked-files=no',
'--ignore-submodules',
'--short',
'--branch',
// Branch flags
'--list',
'--remote',
'--no-abbrev',
'-m',
'-d',
'-D',
// Log flags
'--pretty',
'--oneline',
'--graph',
'--decorate',
// Add flags
'--update',
'-u',
'-A',
// Commit flags
'--amend',
'--no-verify',
'--allow-empty',
// Diff flags
'--stat',
'--cached',
'--staged',
'--unified',
// Misc flags
'--bare',
'--tags',
'--prune',
'--no-ff',
]);
/**
* Validate git command arguments for safety.
*
* SECURITY MODEL:
* We use the runtime adapter which spawns processes with array arguments
* (not shell strings), providing inherent protection from shell injection attacks.
* This works in both Bun (Bun.spawn) and Node.js (child_process.spawn) runtimes.
*
* Characters like ;, |, $, etc. cannot be used for command chaining because
* arguments are passed directly to the git process, not interpreted by a shell.
*
* This function focuses on Git-specific security concerns:
* 1. Null bytes (universally dangerous in many contexts)
* 2. Option flag validation (prevent option injection)
* 3. Path safety (handled elsewhere with sanitization utilities)
*
* WHAT WE DON'T NEED TO VALIDATE:
* - Newlines in commit messages (safe with array spawn, required for multi-line messages)
* - Shell metacharacters like ;, |, $, <, > (safe with array spawn)
* - Backticks and $() (safe with array spawn)
*
* @param args - Arguments to validate
* @throws Error if arguments contain unsafe patterns
*/
export function validateGitArgs(args: string[]): void {
for (const arg of args) {
// Critical: Prevent null bytes which can cause issues in many contexts
// Null bytes can truncate strings unexpectedly and bypass security checks
if (arg.includes('\0')) {
throw new Error(`Null byte detected in git argument: ${arg}`);
}
// Validate option flags (arguments starting with -)
// Allow short flags like -v, -f, etc. which match /-\w/
// Allow long flags that are in our safe list
// Allow flags with values like --format=..., --initial-branch=...
if (arg.startsWith('-')) {
// Extract the flag name (before = if present)
const flagName = arg.split('=')[0] || arg;
// Short flags (single dash + single letter) are generally safe
const isShortFlag = /^-[a-zA-Z]$/.test(flagName);
// Check if it's a known safe option
const isSafeOption = SAFE_GIT_OPTIONS.has(flagName);
// Flags with values (e.g., --format=..., --max-count=...)
const isFlagWithValue = arg.includes('=');
// If it's not a short flag, not in our safe list, and not a recognized pattern,
// we should be cautious. For development, we'll allow it but could make this
// stricter in production environments.
if (!isShortFlag && !isSafeOption && !isFlagWithValue) {
// In a high-security production environment, you might want to throw here
// For now, we allow it to maintain flexibility
// Uncomment the line below for strict validation:
// throw new Error(`Unknown or potentially unsafe git flag: ${arg}`);
}
}
}
}