mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 17:58:30 +08:00
Branch names starting with an underscore (e.g. _release/v1.2.3) are valid per git check-ref-format but were rejected by validateBranchName's first-character whitelist. Since setupBranch validates a PR's baseRefName after checkout, the action failed on every open PR targeting such a branch. A leading underscore carries no option-injection risk (only a leading dash does, which is still rejected separately). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
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();
|
|
});
|
|
|
|
it("should accept branch names containing # (git-valid, common in issue-linked branches)", () => {
|
|
// Reported in #1137: branches like "put-back-arm64-#2" were rejected
|
|
expect(() => validateBranchName("put-back-arm64-#2")).not.toThrow();
|
|
expect(() =>
|
|
validateBranchName("feature/#123-description"),
|
|
).not.toThrow();
|
|
expect(() => validateBranchName("fix/issue-#42")).not.toThrow();
|
|
});
|
|
|
|
it("should accept branch names containing + (generated by Claude Code EnterWorktree)", () => {
|
|
// EnterWorktree converts "/" in worktree names to "+" when generating branch names.
|
|
// e.g. EnterWorktree("feat/skill-consolidation") → branch "worktree-feat+skill-consolidation"
|
|
expect(() =>
|
|
validateBranchName("worktree-feat+skill-consolidation"),
|
|
).not.toThrow();
|
|
expect(() => validateBranchName("fix+issue-123")).not.toThrow();
|
|
expect(() => validateBranchName("feature+new-thing")).not.toThrow();
|
|
});
|
|
|
|
it("should accept branch names containing , (git-valid, common in title-derived branches)", () => {
|
|
// Reported in #1300: branches like "feature/a,b" were rejected, even though
|
|
// git check-ref-format and GitHub both accept commas. Common when branch names
|
|
// are derived from titles, place names, or external identifiers.
|
|
expect(() => validateBranchName("feature/a,b")).not.toThrow();
|
|
expect(() => validateBranchName("feature/paris,france")).not.toThrow();
|
|
expect(() => validateBranchName("fix/issue-1,2,3")).not.toThrow();
|
|
});
|
|
|
|
it("should accept branch names containing @ (git-valid, used in team and tooling conventions)", () => {
|
|
// Reported in #998: branches like "TICKET-123@add-feature" were rejected, even
|
|
// though git check-ref-format and GitHub both accept @ anywhere in a ref name.
|
|
// Also common as a leading prefix (e.g. "@hotfix/...") and in agent-generated
|
|
// names ("task@sessionid"). Bare "@" and "@{" are still rejected.
|
|
expect(() => validateBranchName("TICKET-123@add-feature")).not.toThrow();
|
|
expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow();
|
|
expect(() => validateBranchName("agent/task@abc123")).not.toThrow();
|
|
});
|
|
|
|
it("should accept branch names starting with underscore (git-valid, common for release branches)", () => {
|
|
// Leading underscores are valid per git check-ref-format and a common
|
|
// convention for release/internal branches. Rejecting them broke the
|
|
// action on any open PR whose base branch was e.g. "_release/v1.2.3",
|
|
// since setupBranch validates the PR's baseRefName after checkout.
|
|
expect(() => validateBranchName("_release/v1.2.3")).not.toThrow();
|
|
expect(() => validateBranchName("_internal")).not.toThrow();
|
|
expect(() => validateBranchName("_wip/feature-x")).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 the single character @", () => {
|
|
// Per git-check-ref-format, a refname cannot be the single character "@";
|
|
// "@" also resolves to HEAD in git revision syntax.
|
|
expect(() => validateBranchName("@")).toThrow(/single character '@'/);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|