mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Support GitHub SSH host aliases for PR actions
Resolves the case where a remote like `git@github-work:owner/repo.git` points at github.com via `~/.ssh/config` but Paseo treats it as non-GitHub. Adds a centralized GitHub remote resolver that parses standard remotes directly and resolves SSH aliases via `ssh -G` (with hostname validation to keep the alias out of ssh's option parser). The resolver is shared by workspace GitHub feature detection, PR status, PR creation, and workspace git metadata.
This commit is contained in:
@@ -5190,7 +5190,6 @@ export class Session {
|
||||
base: msg.baseRef,
|
||||
},
|
||||
this.github,
|
||||
this.workspaceGitService,
|
||||
);
|
||||
await this.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename } from "path";
|
||||
import { parseGitHubRemoteUrl } from "../utils/github-remote.js";
|
||||
import { slugify } from "../utils/worktree.js";
|
||||
|
||||
export interface WorkspaceGitMetadata {
|
||||
@@ -14,41 +15,7 @@ export interface WorkspaceGitMetadata {
|
||||
}
|
||||
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
|
||||
let cleaned = remoteUrl.trim();
|
||||
if (!cleaned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(cleaned);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
return parseGitHubRemoteUrl(remoteUrl)?.repo ?? null;
|
||||
}
|
||||
|
||||
export function parseGitHubRepoNameFromRemote(remoteUrl: string): string | null {
|
||||
|
||||
@@ -782,6 +782,32 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("subscription starts GitHub self-heal polling for ssh.github.com remotes", async () => {
|
||||
const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() }));
|
||||
const github = {
|
||||
...createGitHubServiceStub(),
|
||||
retainCurrentPullRequestStatusPoll,
|
||||
};
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, {
|
||||
remoteUrl: "ssh://git@ssh.github.com/acme/repo.git",
|
||||
}),
|
||||
);
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
github,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(retainCurrentPullRequestStatusPoll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("multiple subscribers on the same target share one self-heal timer", async () => {
|
||||
let nowMs = 0;
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("cold getSnapshot calls share one workspace target and cache the snapshot", async () => {
|
||||
test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => {
|
||||
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { createGitHubService, type GitHubService } from "../services/github-service.js";
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js";
|
||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
import {
|
||||
@@ -254,6 +255,7 @@ interface WorkspaceGitTarget {
|
||||
latestFingerprint: string | null;
|
||||
lastShellOutAtMs: number | null;
|
||||
repoGitRoot: string | null;
|
||||
cachedGitHubRemote: { remoteUrl: string; identity: GitHubRemoteIdentity | null } | null;
|
||||
observationSetupPromise: Promise<void> | null;
|
||||
observationSetupComplete: boolean;
|
||||
closed: boolean;
|
||||
@@ -752,6 +754,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
latestFingerprint: null,
|
||||
lastShellOutAtMs: null,
|
||||
repoGitRoot: null,
|
||||
cachedGitHubRemote: null,
|
||||
observationSetupPromise: null,
|
||||
observationSetupComplete: false,
|
||||
closed: false,
|
||||
@@ -1043,7 +1046,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
const headRef = snapshot.git.currentBranch;
|
||||
if (!headRef || !hasGitHubRemoteUrl(snapshot.git.remoteUrl)) {
|
||||
const hasGitHubRemote =
|
||||
target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl &&
|
||||
target.cachedGitHubRemote.identity !== null;
|
||||
if (!headRef || !hasGitHubRemote) {
|
||||
this.stopGitHubPollForTarget(target);
|
||||
return;
|
||||
}
|
||||
@@ -1314,6 +1320,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveGitHubRemoteForTarget(
|
||||
target: WorkspaceGitTarget,
|
||||
remoteUrl: string | null,
|
||||
): Promise<GitHubRemoteIdentity | null> {
|
||||
if (!remoteUrl) {
|
||||
target.cachedGitHubRemote = null;
|
||||
return null;
|
||||
}
|
||||
if (target.cachedGitHubRemote?.remoteUrl === remoteUrl) {
|
||||
return target.cachedGitHubRemote.identity;
|
||||
}
|
||||
const identity = await resolveGitHubRemote({ remoteUrl });
|
||||
target.cachedGitHubRemote = { remoteUrl, identity };
|
||||
return identity;
|
||||
}
|
||||
|
||||
private shouldThrottleNonForcedRefresh(
|
||||
target: WorkspaceGitTarget,
|
||||
): target is WorkspaceGitTarget & {
|
||||
@@ -1386,15 +1408,49 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (forceGitHub) {
|
||||
this.deps.github.invalidate({ cwd: target.cwd });
|
||||
}
|
||||
const snapshot = await loadWorkspaceGitRuntimeSnapshot(
|
||||
target.cwd,
|
||||
{ paseoHome: this.paseoHome },
|
||||
now,
|
||||
this.deps,
|
||||
{ force: request.force, forceGitHub, reason: request.reason },
|
||||
);
|
||||
|
||||
const cwd = target.cwd;
|
||||
const context: CheckoutContext = { paseoHome: this.paseoHome };
|
||||
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
||||
if (!checkoutStatus.isGit) {
|
||||
target.latestSnapshotLoadedAtMs = now.getTime();
|
||||
return buildNotGitSnapshot(cwd);
|
||||
}
|
||||
|
||||
const githubRemote = await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
|
||||
|
||||
const [diffStat, github] = await Promise.all([
|
||||
this.deps.getCheckoutShortstat(cwd, context, { force: request.force }).catch(() => null),
|
||||
loadGitHubSnapshot({
|
||||
cwd,
|
||||
githubRemote,
|
||||
now,
|
||||
deps: this.deps,
|
||||
force: forceGitHub,
|
||||
reason: request.reason,
|
||||
}),
|
||||
]);
|
||||
|
||||
target.latestSnapshotLoadedAtMs = now.getTime();
|
||||
return snapshot;
|
||||
return {
|
||||
cwd,
|
||||
git: {
|
||||
isGit: true,
|
||||
repoRoot: checkoutStatus.repoRoot,
|
||||
mainRepoRoot: checkoutStatus.mainRepoRoot,
|
||||
currentBranch: checkoutStatus.currentBranch,
|
||||
remoteUrl: checkoutStatus.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
|
||||
isDirty: checkoutStatus.isDirty,
|
||||
baseRef: checkoutStatus.baseRef,
|
||||
aheadBehind: checkoutStatus.aheadBehind,
|
||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
||||
hasRemote: checkoutStatus.hasRemote,
|
||||
diffStat,
|
||||
},
|
||||
github,
|
||||
};
|
||||
}
|
||||
|
||||
private rememberSnapshot(
|
||||
@@ -1542,63 +1598,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkspaceGitRuntimeSnapshot(
|
||||
cwd: string,
|
||||
context: CheckoutContext,
|
||||
now: Date,
|
||||
deps: Pick<
|
||||
WorkspaceGitServiceDependencies,
|
||||
"getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "github"
|
||||
>,
|
||||
options?: { force?: boolean; forceGitHub?: boolean; reason?: string },
|
||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||
const checkoutStatus = await deps.getCheckoutStatus(cwd, context);
|
||||
if (!checkoutStatus.isGit) {
|
||||
return buildNotGitSnapshot(cwd);
|
||||
}
|
||||
|
||||
const [diffStat, github] = await Promise.all([
|
||||
deps.getCheckoutShortstat(cwd, context, { force: options?.force }).catch(() => null),
|
||||
loadGitHubSnapshot({
|
||||
cwd,
|
||||
remoteUrl: checkoutStatus.remoteUrl,
|
||||
now,
|
||||
deps,
|
||||
force: options?.forceGitHub,
|
||||
reason: options?.reason,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
cwd,
|
||||
git: {
|
||||
isGit: true,
|
||||
repoRoot: checkoutStatus.repoRoot,
|
||||
mainRepoRoot: checkoutStatus.mainRepoRoot,
|
||||
currentBranch: checkoutStatus.currentBranch,
|
||||
remoteUrl: checkoutStatus.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
|
||||
isDirty: checkoutStatus.isDirty,
|
||||
baseRef: checkoutStatus.baseRef,
|
||||
aheadBehind: checkoutStatus.aheadBehind,
|
||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
||||
hasRemote: checkoutStatus.hasRemote,
|
||||
diffStat,
|
||||
},
|
||||
github,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGitHubSnapshot(options: {
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
githubRemote: GitHubRemoteIdentity | null;
|
||||
now: Date;
|
||||
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
|
||||
force?: boolean;
|
||||
reason?: string;
|
||||
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
|
||||
if (!hasGitHubRemoteUrl(options.remoteUrl)) {
|
||||
if (!options.githubRemote) {
|
||||
return {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
@@ -1637,18 +1645,6 @@ async function loadGitHubSnapshot(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function hasGitHubRemoteUrl(remoteUrl: string | null): boolean {
|
||||
if (!remoteUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
remoteUrl.includes("github.com/") ||
|
||||
remoteUrl.startsWith("git@github.com:") ||
|
||||
remoteUrl.startsWith("ssh://git@github.com/")
|
||||
);
|
||||
}
|
||||
|
||||
function parseWorkspaceGitStashList(
|
||||
stdout: string,
|
||||
options: { paseoOnly: boolean },
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GitHubAuthenticationError,
|
||||
@@ -1837,16 +1841,20 @@ describe("GitHubService", () => {
|
||||
expect(valid.reason).toBe("test");
|
||||
});
|
||||
|
||||
it("resolves GitHub repos from a WorkspaceGitService-owned remote URL", async () => {
|
||||
const workspaceGitService = {
|
||||
resolveRepoRemoteUrl: async (cwd: string) => {
|
||||
expect(cwd).toBe("/repo");
|
||||
return "git@github.com:getpaseo/paseo.git";
|
||||
},
|
||||
};
|
||||
it("resolves GitHub repos from the origin remote URL", async () => {
|
||||
vi.useRealTimers();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "github-service-repo-"));
|
||||
|
||||
await expect(resolveGitHubRepo("/repo", { workspaceGitService })).resolves.toBe(
|
||||
"getpaseo/paseo",
|
||||
);
|
||||
try {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], {
|
||||
cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
await expect(resolveGitHubRepo(cwd)).resolves.toBe("getpaseo/paseo");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { GitHubSearchKind } from "../shared/messages.js";
|
||||
import { findExecutable } from "../utils/executable.js";
|
||||
import { resolveGitHubRemote } from "../utils/github-remote.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
|
||||
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
|
||||
@@ -1865,46 +1867,15 @@ function mapReviewDecision(value: unknown): PullRequestReviewDecision {
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface GitHubRepoRemoteUrlResolver {
|
||||
resolveRepoRemoteUrl(cwd: string, options?: GitHubReadOptions): Promise<string | null>;
|
||||
}
|
||||
|
||||
export async function resolveGitHubRepo(
|
||||
cwd: string,
|
||||
options: { workspaceGitService: GitHubRepoRemoteUrlResolver; readOptions?: GitHubReadOptions },
|
||||
): Promise<string | null> {
|
||||
export async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const remoteUrl = await options.workspaceGitService.resolveRepoRemoteUrl(
|
||||
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
cwd,
|
||||
options.readOptions,
|
||||
);
|
||||
return parseGitHubRepoFromRemote(remoteUrl?.trim() ?? "");
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
||||
});
|
||||
const remote = await resolveGitHubRemote({ remoteUrl: stdout.trim() });
|
||||
return remote?.repo ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGitHubRepoFromRemote(url: string): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
let cleaned = url;
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else if (cleaned.startsWith("https://github.com/")) {
|
||||
cleaned = cleaned.slice("https://github.com/".length);
|
||||
} else if (cleaned.startsWith("http://github.com/")) {
|
||||
cleaned = cleaned.slice("http://github.com/".length);
|
||||
} else {
|
||||
const marker = "github.com/";
|
||||
const index = cleaned.indexOf(marker);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
cleaned = cleaned.slice(index + marker.length);
|
||||
}
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
return cleaned.includes("/") ? cleaned : null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
GitHubCommandError,
|
||||
createGitHubService,
|
||||
resolveGitHubRepo,
|
||||
type GitHubRepoRemoteUrlResolver,
|
||||
type GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
@@ -2277,11 +2276,10 @@ export async function createPullRequest(
|
||||
cwd: string,
|
||||
options: CreatePullRequestOptions,
|
||||
github: GitHubService = createGitHubService(),
|
||||
workspaceGitService: GitHubRepoRemoteUrlResolver,
|
||||
context?: CheckoutContext,
|
||||
): Promise<{ url: string; number: number }> {
|
||||
await requireGitRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd, { workspaceGitService });
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
if (!repo) {
|
||||
throw new Error("Unable to determine GitHub repo from git remote");
|
||||
}
|
||||
|
||||
109
packages/server/src/utils/github-remote.test.ts
Normal file
109
packages/server/src/utils/github-remote.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseGitHubRemoteUrl, resolveGitHubRemote } from "./github-remote.js";
|
||||
|
||||
function createSshHostnameResolver(hostnameByAlias: Record<string, string | null>) {
|
||||
return async (host: string): Promise<string | null> => {
|
||||
return hostnameByAlias[host] ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseGitHubRemoteUrl", () => {
|
||||
it.each([
|
||||
["https://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["http://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["git@github.com:acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["ssh://git@github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["ssh://git@ssh.github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
])("parses direct GitHub remotes: %s", (remoteUrl, expected) => {
|
||||
expect(parseGitHubRemoteUrl(remoteUrl)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub remotes", () => {
|
||||
expect(parseGitHubRemoteUrl("git@gitlab.com:acme/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for SSH aliases before hostname resolution", () => {
|
||||
expect(parseGitHubRemoteUrl("git@github-work:acme/repo.git")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitHubRemote", () => {
|
||||
it("resolves an SSH alias that maps to github.com", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "git@github-work:acme/repo.git",
|
||||
resolveSshHostname: createSshHostnameResolver({ "github-work": "github.com" }),
|
||||
}),
|
||||
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
|
||||
});
|
||||
|
||||
it("resolves a dotted SSH alias that maps to github.com", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "git@mindnexus.github.com:JakubMindNexus/postline.git",
|
||||
resolveSshHostname: createSshHostnameResolver({
|
||||
"mindnexus.github.com": "github.com",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
owner: "JakubMindNexus",
|
||||
name: "postline",
|
||||
repo: "JakubMindNexus/postline",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves an SSH alias that maps to ssh.github.com", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "ssh://git@github-work/acme/repo.git",
|
||||
resolveSshHostname: createSshHostnameResolver({ "github-work": "ssh.github.com" }),
|
||||
}),
|
||||
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
|
||||
});
|
||||
|
||||
it("returns null when an SSH alias resolves to a non-GitHub host", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "git@github-work:acme/repo.git",
|
||||
resolveSshHostname: createSshHostnameResolver({ "github-work": "gitlab.com" }),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not use SSH hostname resolution for HTTPS remotes", async () => {
|
||||
let resolverCalls = 0;
|
||||
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
resolveSshHostname: async () => {
|
||||
resolverCalls += 1;
|
||||
return "github.com";
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
|
||||
|
||||
expect(resolverCalls).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"git@-oProxyCommand=evil:acme/repo.git",
|
||||
"ssh://git@-oProxyCommand=evil/acme/repo.git",
|
||||
"git@host with space:acme/repo.git",
|
||||
])("rejects malformed hostnames without invoking the SSH resolver: %s", async (remoteUrl) => {
|
||||
let resolverCalls = 0;
|
||||
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl,
|
||||
resolveSshHostname: async (host) => {
|
||||
resolverCalls += 1;
|
||||
return host;
|
||||
},
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(resolverCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
145
packages/server/src/utils/github-remote.ts
Normal file
145
packages/server/src/utils/github-remote.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { findExecutable } from "./executable.js";
|
||||
import { execCommand } from "./spawn.js";
|
||||
|
||||
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
|
||||
|
||||
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
||||
"https:": "https",
|
||||
"http:": "http",
|
||||
"ssh:": "ssh",
|
||||
};
|
||||
|
||||
let sshExecutableLookup: Promise<string | null> | null = null;
|
||||
const sshHostnameResolutionCache = new Map<string, Promise<string | null>>();
|
||||
|
||||
interface GitRemoteLocation {
|
||||
transport: "scp" | "ssh" | "http" | "https";
|
||||
host: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface GitHubRemoteIdentity {
|
||||
owner: string;
|
||||
name: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export type SshHostnameResolver = (host: string) => Promise<string | null>;
|
||||
|
||||
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const location = parseGitRemoteLocation(remoteUrl);
|
||||
if (!location || !GITHUB_HOSTS.has(location.host)) return null;
|
||||
return parseGitHubRemoteIdentity(location.path);
|
||||
}
|
||||
|
||||
export async function resolveGitHubRemote(input: {
|
||||
remoteUrl: string;
|
||||
resolveSshHostname?: SshHostnameResolver;
|
||||
}): Promise<GitHubRemoteIdentity | null> {
|
||||
const location = parseGitRemoteLocation(input.remoteUrl);
|
||||
if (!location) return null;
|
||||
if (GITHUB_HOSTS.has(location.host)) return parseGitHubRemoteIdentity(location.path);
|
||||
if (location.transport !== "scp" && location.transport !== "ssh") return null;
|
||||
|
||||
const resolve = input.resolveSshHostname ?? resolveSshHostname;
|
||||
const resolvedHost = await resolve(location.host);
|
||||
if (!resolvedHost || !GITHUB_HOSTS.has(resolvedHost)) return null;
|
||||
return parseGitHubRemoteIdentity(location.path);
|
||||
}
|
||||
|
||||
export async function resolveSshHostname(host: string): Promise<string | null> {
|
||||
const normalized = normalizeHost(host);
|
||||
if (!normalized) return null;
|
||||
|
||||
const cached = sshHostnameResolutionCache.get(normalized);
|
||||
if (cached) return cached;
|
||||
|
||||
const resolution = runSshHostnameLookup(normalized);
|
||||
sshHostnameResolutionCache.set(normalized, resolution);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
async function runSshHostnameLookup(host: string): Promise<string | null> {
|
||||
sshExecutableLookup ??= findExecutable("ssh");
|
||||
const sshPath = await sshExecutableLookup;
|
||||
if (!sshPath) return null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execCommand(sshPath, ["-G", host], {
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return parseSshHostname(stdout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSshHostname(stdout: string): string | null {
|
||||
for (const line of stdout.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [key, value] = trimmed.split(/\s+/u);
|
||||
if (key?.toLowerCase() !== "hostname") continue;
|
||||
const normalized = normalizeHost(value ?? "");
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
||||
if (scpLike) {
|
||||
const host = normalizeHost(scpLike[1] ?? "");
|
||||
const path = normalizeRemotePath(scpLike[2] ?? "");
|
||||
if (!isValidRemoteHost(host) || !path) return null;
|
||||
return { transport: "scp", host, path };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()];
|
||||
if (!transport) return null;
|
||||
|
||||
const host = normalizeHost(parsed.hostname);
|
||||
let path: string;
|
||||
try {
|
||||
path = decodeURIComponent(parsed.pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = normalizeRemotePath(path);
|
||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||
|
||||
return { transport, host, path: normalizedPath };
|
||||
}
|
||||
|
||||
function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length !== 2) return null;
|
||||
const [owner, name] = segments;
|
||||
if (!owner || !name) return null;
|
||||
return { owner, name, repo: `${owner}/${name}` };
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string | null {
|
||||
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
|
||||
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().replace(/\.+$/u, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isValidRemoteHost(host: string): boolean {
|
||||
return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host);
|
||||
}
|
||||
Reference in New Issue
Block a user