mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
Compare commits
6 Commits
v1.0.23
...
claude/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68df0f8000 | ||
|
|
67bf0594ce | ||
|
|
b58533dbe0 | ||
|
|
bda9bf08de | ||
|
|
79b343c094 | ||
|
|
609c388361 |
@@ -127,6 +127,9 @@ outputs:
|
||||
structured_output:
|
||||
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
|
||||
value: ${{ steps.claude-code.outputs.structured_output }}
|
||||
session_id:
|
||||
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
|
||||
value: ${{ steps.claude-code.outputs.session_id }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -195,7 +198,7 @@ runs:
|
||||
|
||||
# Install Claude Code if no custom executable is provided
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.0.62"
|
||||
CLAUDE_CODE_VERSION="2.0.69"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
|
||||
@@ -82,6 +82,9 @@ outputs:
|
||||
structured_output:
|
||||
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
|
||||
value: ${{ steps.run_claude.outputs.structured_output }}
|
||||
session_id:
|
||||
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
|
||||
value: ${{ steps.run_claude.outputs.session_id }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -121,7 +124,7 @@ runs:
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
run: |
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.0.62"
|
||||
CLAUDE_CODE_VERSION="2.0.69"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
|
||||
@@ -124,6 +124,36 @@ export function prepareRunConfig(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses session_id from execution file and sets GitHub Action output
|
||||
* Exported for testing
|
||||
*/
|
||||
export async function parseAndSetSessionId(
|
||||
executionFile: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(executionFile, "utf-8");
|
||||
const messages = JSON.parse(content) as {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
}[];
|
||||
|
||||
// Find the system.init message which contains session_id
|
||||
const initMessage = messages.find(
|
||||
(m) => m.type === "system" && m.subtype === "init",
|
||||
);
|
||||
|
||||
if (initMessage?.session_id) {
|
||||
core.setOutput("session_id", initMessage.session_id);
|
||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the action if session_id extraction fails
|
||||
core.warning(`Failed to extract session_id: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses structured_output from execution file and sets GitHub Action outputs
|
||||
* Only runs if --json-schema was explicitly provided in claude_args
|
||||
@@ -167,8 +197,8 @@ export async function parseAndSetStructuredOutputs(
|
||||
}
|
||||
|
||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
// Feature flag: use SDK path when USE_AGENT_SDK=true
|
||||
const useAgentSdk = process.env.USE_AGENT_SDK === "true";
|
||||
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
|
||||
const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
|
||||
console.log(
|
||||
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
|
||||
);
|
||||
@@ -368,6 +398,9 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
|
||||
// Extract and set session_id
|
||||
await parseAndSetSessionId(EXECUTION_FILE);
|
||||
|
||||
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
||||
if (hasJsonSchema) {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,10 @@ import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
||||
import { writeFile, unlink } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { parseAndSetStructuredOutputs } from "../src/run-claude";
|
||||
import {
|
||||
parseAndSetStructuredOutputs,
|
||||
parseAndSetSessionId,
|
||||
} from "../src/run-claude";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
// Mock execution file path
|
||||
@@ -35,16 +38,19 @@ async function createMockExecutionFile(
|
||||
// Spy on core functions
|
||||
let setOutputSpy: any;
|
||||
let infoSpy: any;
|
||||
let warningSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("parseAndSetStructuredOutputs", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
@@ -156,3 +162,66 @@ describe("parseAndSetStructuredOutputs", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAndSetSessionId", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should extract session_id from system.init message", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init", session_id: "test-session-123" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
|
||||
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
|
||||
});
|
||||
|
||||
test("should handle missing session_id gracefully", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle missing system.init message gracefully", async () => {
|
||||
const messages = [{ type: "result", cost_usd: 0.01 }];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle malformed JSON gracefully with warning", async () => {
|
||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle non-existent file gracefully with warning", async () => {
|
||||
await parseAndSetSessionId("/nonexistent/file.json");
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -563,9 +563,22 @@ ${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
|
||||
${
|
||||
eventData.claudeBranch
|
||||
? `
|
||||
When done with changes, provide a PR link:
|
||||
When done with changes:
|
||||
1. Run git log origin/${eventData.baseBranch}..HEAD and git diff origin/${eventData.baseBranch}...HEAD to understand ALL commits
|
||||
2. Draft a PR summary analyzing ALL changes (not just the latest commit)
|
||||
3. Provide a PR link:
|
||||
[Create a PR](${GITHUB_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...${eventData.claudeBranch}?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
||||
Use THREE dots (...) between branches. URL-encode all parameters.`
|
||||
Use THREE dots (...) between branches. URL-encode all parameters.
|
||||
PR body format:
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
<Checklist of testing TODOs>
|
||||
|
||||
Fixes #<issue-number>
|
||||
|
||||
Generated with [Claude Code](https://claude.ai/code)`
|
||||
: ""
|
||||
}
|
||||
|
||||
@@ -743,8 +756,13 @@ ${eventData.eventName === "issue_comment" || eventData.eventName === "pull_reque
|
||||
- Mark each subtask as completed as you progress.${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
|
||||
${
|
||||
eventData.claudeBranch
|
||||
? `- Provide a URL to create a PR manually in this format:
|
||||
[Create a PR](${GITHUB_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...<branch-name>?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
||||
? `- When creating a pull request, follow these steps:
|
||||
1. Use git log and git diff to understand the full commit history for the current branch (from the time it diverged from the base branch):
|
||||
- Run: git log origin/${eventData.baseBranch}..HEAD
|
||||
- Run: git diff origin/${eventData.baseBranch}...HEAD
|
||||
2. Analyze ALL changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request), and draft a pull request summary
|
||||
3. Provide a URL to create a PR manually in this format:
|
||||
[Create a PR](${GITHUB_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...<branch-name>?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
||||
- IMPORTANT: Use THREE dots (...) between branch names, not two (..)
|
||||
Example: ${GITHUB_SERVER_URL}/${context.repository}/compare/main...feature-branch (correct)
|
||||
NOT: ${GITHUB_SERVER_URL}/${context.repository}/compare/main..feature-branch (incorrect)
|
||||
@@ -752,10 +770,16 @@ ${eventData.eventName === "issue_comment" || eventData.eventName === "pull_reque
|
||||
Example: Instead of "fix: update welcome message", use "fix%3A%20update%20welcome%20message"
|
||||
- The target-branch should be '${eventData.baseBranch}'.
|
||||
- The branch-name is the current branch: ${eventData.claudeBranch}
|
||||
- The body should include:
|
||||
- A clear description of the changes
|
||||
- Reference to the original ${eventData.isPR ? "PR" : "issue"}
|
||||
- The signature: "Generated with [Claude Code](https://claude.ai/code)"
|
||||
- The PR body MUST follow this format:
|
||||
## Summary
|
||||
<1-3 bullet points summarizing the changes>
|
||||
|
||||
## Test plan
|
||||
<Bulleted markdown checklist of TODOs for testing the pull request>
|
||||
|
||||
Fixes #<issue-number>
|
||||
|
||||
Generated with [Claude Code](https://claude.ai/code)
|
||||
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
|
||||
: ""
|
||||
}
|
||||
|
||||
@@ -6,13 +6,112 @@
|
||||
* - For Issues: Create a new branch
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { execFileSync } from "child_process";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
import type { Octokits } from "../api/client";
|
||||
import type { FetchDataResult } from "../data/fetcher";
|
||||
|
||||
/**
|
||||
* Validates a git branch name against a strict whitelist pattern.
|
||||
* This prevents command injection by ensuring only safe characters are used.
|
||||
*
|
||||
* Valid branch names:
|
||||
* - Start with alphanumeric character (not dash, to prevent option injection)
|
||||
* - Contain only alphanumeric, forward slash, hyphen, underscore, or period
|
||||
* - Do not start or end with a period
|
||||
* - Do not end with a slash
|
||||
* - Do not contain '..' (path traversal)
|
||||
* - Do not contain '//' (consecutive slashes)
|
||||
* - Do not end with '.lock'
|
||||
* - Do not contain '@{'
|
||||
* - Do not contain control characters or special git characters (~^:?*[\])
|
||||
*/
|
||||
export function validateBranchName(branchName: string): void {
|
||||
// Check for empty or whitespace-only names
|
||||
if (!branchName || branchName.trim().length === 0) {
|
||||
throw new Error("Branch name cannot be empty");
|
||||
}
|
||||
|
||||
// Check for leading dash (prevents option injection like --help, -x)
|
||||
if (branchName.startsWith("-")) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot start with a dash.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for control characters and special git characters (~^:?*[\])
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1F\x7F ~^:?*[\]\\]/.test(branchName)) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot contain control characters, spaces, or special git characters (~^:?*[\\]).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period
|
||||
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/;
|
||||
|
||||
if (!validPattern.test(branchName)) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, or periods.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for leading/trailing periods
|
||||
if (branchName.startsWith(".") || branchName.endsWith(".")) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot start or end with a period.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for trailing slash
|
||||
if (branchName.endsWith("/")) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot end with a slash.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for consecutive slashes
|
||||
if (branchName.includes("//")) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot contain consecutive slashes.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Additional git-specific validations
|
||||
if (branchName.includes("..")) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot contain '..'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (branchName.endsWith(".lock")) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot end with '.lock'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (branchName.includes("@{")) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a git command safely using execFileSync to avoid shell interpolation.
|
||||
*
|
||||
* Security: execFileSync passes arguments directly to the git binary without
|
||||
* invoking a shell, preventing command injection attacks where malicious input
|
||||
* could be interpreted as shell commands (e.g., branch names containing `;`, `|`, `&&`).
|
||||
*
|
||||
* @param args - Git command arguments (e.g., ["checkout", "branch-name"])
|
||||
*/
|
||||
function execGit(args: string[]): void {
|
||||
execFileSync("git", args, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
export type BranchInfo = {
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
@@ -53,14 +152,19 @@ export async function setupBranch(
|
||||
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
||||
);
|
||||
|
||||
// Validate branch names before use to prevent command injection
|
||||
validateBranchName(branchName);
|
||||
|
||||
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
||||
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
|
||||
await $`git checkout ${branchName} --`;
|
||||
// Using execFileSync instead of shell template literals for security
|
||||
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
|
||||
execGit(["checkout", branchName, "--"]);
|
||||
|
||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||
|
||||
// For open PRs, we need to get the base branch of the PR
|
||||
const baseBranch = prData.baseRefName;
|
||||
validateBranchName(baseBranch);
|
||||
|
||||
return {
|
||||
baseBranch,
|
||||
@@ -118,8 +222,9 @@ export async function setupBranch(
|
||||
|
||||
// Ensure we're on the source branch
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
validateBranchName(sourceBranch);
|
||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
||||
execGit(["checkout", sourceBranch, "--"]);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
@@ -138,11 +243,13 @@ export async function setupBranch(
|
||||
|
||||
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
validateBranchName(sourceBranch);
|
||||
validateBranchName(newBranch);
|
||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
||||
execGit(["checkout", sourceBranch, "--"]);
|
||||
|
||||
// Create and checkout the new branch from the source branch
|
||||
await $`git checkout -b ${newBranch}`;
|
||||
execGit(["checkout", "-b", newBranch]);
|
||||
|
||||
console.log(
|
||||
`Successfully created and checked out local branch: ${newBranch}`,
|
||||
|
||||
201
test/validate-branch-name.test.ts
Normal file
201
test/validate-branch-name.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { validateBranchName } from "../src/github/operations/branch";
|
||||
|
||||
describe("validateBranchName", () => {
|
||||
describe("valid branch names", () => {
|
||||
it("should accept simple alphanumeric names", () => {
|
||||
expect(() => validateBranchName("main")).not.toThrow();
|
||||
expect(() => validateBranchName("feature123")).not.toThrow();
|
||||
expect(() => validateBranchName("Branch1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept names with hyphens", () => {
|
||||
expect(() => validateBranchName("feature-branch")).not.toThrow();
|
||||
expect(() => validateBranchName("fix-bug-123")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept names with underscores", () => {
|
||||
expect(() => validateBranchName("feature_branch")).not.toThrow();
|
||||
expect(() => validateBranchName("fix_bug_123")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept names with forward slashes", () => {
|
||||
expect(() => validateBranchName("feature/new-thing")).not.toThrow();
|
||||
expect(() => validateBranchName("user/feature/branch")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept names with periods", () => {
|
||||
expect(() => validateBranchName("v1.0.0")).not.toThrow();
|
||||
expect(() => validateBranchName("release.1.2.3")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept typical branch name formats", () => {
|
||||
expect(() =>
|
||||
validateBranchName("claude/issue-123-20250101-1234"),
|
||||
).not.toThrow();
|
||||
expect(() => validateBranchName("refs/heads/main")).not.toThrow();
|
||||
expect(() => validateBranchName("bugfix/JIRA-1234")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command injection attempts", () => {
|
||||
it("should reject shell command substitution with $()", () => {
|
||||
expect(() => validateBranchName("$(whoami)")).toThrow();
|
||||
expect(() => validateBranchName("branch-$(rm -rf /)")).toThrow();
|
||||
expect(() => validateBranchName("test$(cat /etc/passwd)")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject shell command substitution with backticks", () => {
|
||||
expect(() => validateBranchName("`whoami`")).toThrow();
|
||||
expect(() => validateBranchName("branch-`rm -rf /`")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject command chaining with semicolons", () => {
|
||||
expect(() => validateBranchName("branch; rm -rf /")).toThrow();
|
||||
expect(() => validateBranchName("test;whoami")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject command chaining with &&", () => {
|
||||
expect(() => validateBranchName("branch && rm -rf /")).toThrow();
|
||||
expect(() => validateBranchName("test&&whoami")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject command chaining with ||", () => {
|
||||
expect(() => validateBranchName("branch || rm -rf /")).toThrow();
|
||||
expect(() => validateBranchName("test||whoami")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject pipe characters", () => {
|
||||
expect(() => validateBranchName("branch | cat")).toThrow();
|
||||
expect(() => validateBranchName("test|grep password")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject redirection operators", () => {
|
||||
expect(() => validateBranchName("branch > /etc/passwd")).toThrow();
|
||||
expect(() => validateBranchName("branch < input")).toThrow();
|
||||
expect(() => validateBranchName("branch >> file")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("option injection attempts", () => {
|
||||
it("should reject branch names starting with dash", () => {
|
||||
expect(() => validateBranchName("-x")).toThrow(
|
||||
/cannot start with a dash/,
|
||||
);
|
||||
expect(() => validateBranchName("--help")).toThrow(
|
||||
/cannot start with a dash/,
|
||||
);
|
||||
expect(() => validateBranchName("-")).toThrow(/cannot start with a dash/);
|
||||
expect(() => validateBranchName("--version")).toThrow(
|
||||
/cannot start with a dash/,
|
||||
);
|
||||
expect(() => validateBranchName("-rf")).toThrow(
|
||||
/cannot start with a dash/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path traversal attempts", () => {
|
||||
it("should reject double dot sequences", () => {
|
||||
expect(() => validateBranchName("../../../etc")).toThrow();
|
||||
expect(() => validateBranchName("branch/../secret")).toThrow(/'\.\.'$/);
|
||||
expect(() => validateBranchName("a..b")).toThrow(/'\.\.'$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-specific invalid patterns", () => {
|
||||
it("should reject @{ sequence", () => {
|
||||
expect(() => validateBranchName("branch@{1}")).toThrow(/@{/);
|
||||
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
|
||||
});
|
||||
|
||||
it("should reject .lock suffix", () => {
|
||||
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
|
||||
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
|
||||
});
|
||||
|
||||
it("should reject consecutive slashes", () => {
|
||||
expect(() => validateBranchName("feature//branch")).toThrow(
|
||||
/consecutive slashes/,
|
||||
);
|
||||
expect(() => validateBranchName("a//b//c")).toThrow(
|
||||
/consecutive slashes/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject trailing slashes", () => {
|
||||
expect(() => validateBranchName("feature/")).toThrow(
|
||||
/cannot end with a slash/,
|
||||
);
|
||||
expect(() => validateBranchName("branch/")).toThrow(
|
||||
/cannot end with a slash/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject leading periods", () => {
|
||||
expect(() => validateBranchName(".hidden")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject trailing periods", () => {
|
||||
expect(() => validateBranchName("branch.")).toThrow(
|
||||
/cannot start or end with a period/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject special git refspec characters", () => {
|
||||
expect(() => validateBranchName("branch~1")).toThrow();
|
||||
expect(() => validateBranchName("branch^2")).toThrow();
|
||||
expect(() => validateBranchName("branch:ref")).toThrow();
|
||||
expect(() => validateBranchName("branch?")).toThrow();
|
||||
expect(() => validateBranchName("branch*")).toThrow();
|
||||
expect(() => validateBranchName("branch[0]")).toThrow();
|
||||
expect(() => validateBranchName("branch\\path")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("control characters and special characters", () => {
|
||||
it("should reject null bytes", () => {
|
||||
expect(() => validateBranchName("branch\x00name")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject other control characters", () => {
|
||||
expect(() => validateBranchName("branch\x01name")).toThrow();
|
||||
expect(() => validateBranchName("branch\x1Fname")).toThrow();
|
||||
expect(() => validateBranchName("branch\x7Fname")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject spaces", () => {
|
||||
expect(() => validateBranchName("branch name")).toThrow();
|
||||
expect(() => validateBranchName("feature branch")).toThrow();
|
||||
});
|
||||
|
||||
it("should reject newlines and tabs", () => {
|
||||
expect(() => validateBranchName("branch\nname")).toThrow();
|
||||
expect(() => validateBranchName("branch\tname")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty and whitespace", () => {
|
||||
it("should reject empty strings", () => {
|
||||
expect(() => validateBranchName("")).toThrow(/cannot be empty/);
|
||||
});
|
||||
|
||||
it("should reject whitespace-only strings", () => {
|
||||
expect(() => validateBranchName(" ")).toThrow();
|
||||
expect(() => validateBranchName("\t\n")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should accept single alphanumeric character", () => {
|
||||
expect(() => validateBranchName("a")).not.toThrow();
|
||||
expect(() => validateBranchName("1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should reject single special characters", () => {
|
||||
expect(() => validateBranchName(".")).toThrow();
|
||||
expect(() => validateBranchName("/")).toThrow();
|
||||
expect(() => validateBranchName("-")).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user