359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
export type ExternalVcsProvider = "github" | "gitlab" | "azure_devops";
|
|
|
|
export type ExternalVcsRepository = {
|
|
provider: string;
|
|
provider_host?: string;
|
|
provider_owner: string;
|
|
provider_name: string;
|
|
remote_url?: string;
|
|
};
|
|
|
|
export type ExternalVcsFileURLInput = {
|
|
repository: ExternalVcsRepository;
|
|
path: string;
|
|
previousPath?: string | null;
|
|
status?: string | null;
|
|
publishedBranch?: string | null;
|
|
baseBranch?: string | null;
|
|
};
|
|
|
|
export type ExternalVcsFileURL = {
|
|
provider: ExternalVcsProvider;
|
|
url: string;
|
|
path: string;
|
|
revision: string;
|
|
};
|
|
|
|
type ResolvedTarget = { path: string; revision: string };
|
|
type SSHCloneIdentity = { hostname: string; parts: string[] };
|
|
|
|
function cleanValue(value: string | null | undefined): string {
|
|
return value?.trim() ?? "";
|
|
}
|
|
|
|
function isSafeRef(value: string): boolean {
|
|
return value.length > 0 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
}
|
|
|
|
function isSafeRepositoryPath(value: string): boolean {
|
|
if (
|
|
!value ||
|
|
value.startsWith("/") ||
|
|
value.startsWith("\\") ||
|
|
value.includes("\\") ||
|
|
/[\u0000-\u001f\u007f]/.test(value)
|
|
) {
|
|
return false;
|
|
}
|
|
const segments = value.split("/");
|
|
return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
}
|
|
|
|
function selectTarget(input: ExternalVcsFileURLInput): ResolvedTarget | null {
|
|
const publishedBranch = cleanValue(input.publishedBranch);
|
|
const baseBranch = cleanValue(input.baseBranch);
|
|
const currentPath = input.path;
|
|
const previousPath = input.previousPath ?? "";
|
|
const status = cleanValue(input.status).toLowerCase();
|
|
|
|
if (status === "deleted") {
|
|
return baseBranch ? { path: currentPath, revision: baseBranch } : null;
|
|
}
|
|
if (status === "renamed") {
|
|
if (publishedBranch) {
|
|
return { path: currentPath, revision: publishedBranch };
|
|
}
|
|
return baseBranch && previousPath ? { path: previousPath, revision: baseBranch } : null;
|
|
}
|
|
if ((status === "added" || status === "untracked") && !publishedBranch) return null;
|
|
const revision = publishedBranch || baseBranch;
|
|
return revision ? { path: currentPath, revision } : null;
|
|
}
|
|
|
|
function parseHTTPSRemote(rawRemoteURL: string | undefined): URL | null {
|
|
if (!rawRemoteURL || /[\u0000-\u001f\u007f]/.test(rawRemoteURL)) return null;
|
|
try {
|
|
const remote = new URL(cleanValue(rawRemoteURL));
|
|
if (
|
|
remote.protocol !== "https:" ||
|
|
remote.username ||
|
|
remote.password ||
|
|
remote.search ||
|
|
remote.hash
|
|
) {
|
|
return null;
|
|
}
|
|
return remote;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function decodeSSHPath(rawPath: string): string[] | null {
|
|
try {
|
|
const rawParts = rawPath.replace(/^\/+/, "").split("/");
|
|
if (rawParts.some((part) => !part)) return null;
|
|
const parts = rawParts.map((part) => decodeURIComponent(part));
|
|
const lastIndex = parts.length - 1;
|
|
parts[lastIndex] = parts[lastIndex].replace(/\.git$/, "");
|
|
if (
|
|
parts.some(
|
|
(part) =>
|
|
!part ||
|
|
part === "." ||
|
|
part === ".." ||
|
|
/[\\/\u0000-\u001f\u007f]/.test(part) ||
|
|
/%[0-9a-f]{2}/i.test(part),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return parts;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function hasValidSSHPort(value: string): boolean {
|
|
const authorityEnd = value.indexOf("/", "ssh://".length);
|
|
if (authorityEnd < 0) return false;
|
|
const authority = value.slice("ssh://".length, authorityEnd);
|
|
const hostnameAndPort = authority.slice(authority.lastIndexOf("@") + 1);
|
|
const separator = hostnameAndPort.lastIndexOf(":");
|
|
if (separator < 0) return true;
|
|
const port = hostnameAndPort.slice(separator + 1);
|
|
if (!/^\d+$/.test(port)) return false;
|
|
const numericPort = Number(port);
|
|
return numericPort >= 1 && numericPort <= 65535;
|
|
}
|
|
|
|
function parseSSHCloneIdentity(rawRemoteURL: string | undefined): SSHCloneIdentity | null {
|
|
if (!rawRemoteURL || /[\u0000-\u001f\u007f]/.test(rawRemoteURL)) return null;
|
|
const value = cleanValue(rawRemoteURL);
|
|
const scpMatch = /^git@([A-Za-z0-9.-]+):([^?#]+)$/.exec(value);
|
|
if (scpMatch) {
|
|
const parts = decodeSSHPath(scpMatch[2]);
|
|
return parts ? { hostname: scpMatch[1].toLowerCase(), parts } : null;
|
|
}
|
|
|
|
try {
|
|
const remote = new URL(value);
|
|
if (
|
|
remote.protocol !== "ssh:" ||
|
|
remote.username !== "git" ||
|
|
remote.password ||
|
|
remote.search ||
|
|
remote.hash ||
|
|
!hasValidSSHPort(value)
|
|
) {
|
|
return null;
|
|
}
|
|
const parts = decodeSSHPath(remote.pathname);
|
|
return parts ? { hostname: remote.hostname.toLowerCase(), parts } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function decodedRemoteParts(remote: URL): string[] | null {
|
|
try {
|
|
const path = remote.pathname.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
return path
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((part) => decodeURIComponent(part));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseProviderOrigin(rawProviderHost: string | undefined): string | null {
|
|
if (!rawProviderHost || /[\u0000-\u001f\u007f]/.test(rawProviderHost)) return null;
|
|
try {
|
|
const host = new URL(cleanValue(rawProviderHost));
|
|
if (
|
|
host.protocol !== "https:" ||
|
|
host.username ||
|
|
host.password ||
|
|
host.search ||
|
|
host.hash ||
|
|
!/^\/?$/.test(host.pathname)
|
|
) {
|
|
return null;
|
|
}
|
|
return host.origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function repositoryPathMatches(parts: string[], owner: string, name: string): boolean {
|
|
const expected = owner.split("/").concat(name);
|
|
return (
|
|
expected.every(
|
|
(part) =>
|
|
part &&
|
|
part !== "." &&
|
|
part !== ".." &&
|
|
!/[\\\u0000-\u001f\u007f]/.test(part) &&
|
|
!/%[0-9a-f]{2}/i.test(part),
|
|
) &&
|
|
parts.length === expected.length &&
|
|
parts.every((part, index) => part === expected[index])
|
|
);
|
|
}
|
|
|
|
function sshRemoteForRepository(repository: ExternalVcsRepository): URL | null {
|
|
const identity = parseSSHCloneIdentity(repository.remote_url);
|
|
if (!identity) return null;
|
|
const provider = repository.provider.toLowerCase();
|
|
|
|
if (
|
|
provider === "github" &&
|
|
identity.hostname === "github.com" &&
|
|
repositoryPathMatches(identity.parts, repository.provider_owner, repository.provider_name)
|
|
) {
|
|
return new URL(
|
|
`https://github.com/${encodeRepositoryPath(repository.provider_owner, repository.provider_name)}`,
|
|
);
|
|
}
|
|
|
|
if (provider === "gitlab") {
|
|
const origin = parseProviderOrigin(repository.provider_host);
|
|
if (
|
|
origin &&
|
|
identity.hostname === new URL(origin).hostname.toLowerCase() &&
|
|
repositoryPathMatches(identity.parts, repository.provider_owner, repository.provider_name)
|
|
) {
|
|
return new URL(
|
|
`${origin}/${encodeRepositoryPath(repository.provider_owner, repository.provider_name)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
provider === "azure_devops" &&
|
|
identity.hostname === "ssh.dev.azure.com" &&
|
|
identity.parts.length === 4 &&
|
|
identity.parts[0] === "v3" &&
|
|
identity.parts[2] === repository.provider_owner &&
|
|
identity.parts[3] === repository.provider_name
|
|
) {
|
|
const organization = encodeURIComponent(identity.parts[1]);
|
|
const project = encodeURIComponent(repository.provider_owner);
|
|
const name = encodeURIComponent(repository.provider_name);
|
|
return new URL(`https://dev.azure.com/${organization}/${project}/_git/${name}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseRepositoryRemote(repository: ExternalVcsRepository): URL | null {
|
|
return parseHTTPSRemote(repository.remote_url) ?? sshRemoteForRepository(repository);
|
|
}
|
|
|
|
function githubRemoteMatches(
|
|
repository: ExternalVcsRepository,
|
|
remote: URL,
|
|
parts: string[],
|
|
): boolean {
|
|
const origin = parseProviderOrigin(repository.provider_host) ?? "https://github.com";
|
|
return (
|
|
remote.origin === origin &&
|
|
origin === "https://github.com" &&
|
|
repositoryPathMatches(parts, repository.provider_owner, repository.provider_name)
|
|
);
|
|
}
|
|
|
|
function gitlabRemoteMatches(
|
|
repository: ExternalVcsRepository,
|
|
remote: URL,
|
|
parts: string[],
|
|
): boolean {
|
|
const origin = parseProviderOrigin(repository.provider_host);
|
|
return Boolean(
|
|
origin &&
|
|
remote.origin === origin &&
|
|
repositoryPathMatches(parts, repository.provider_owner, repository.provider_name),
|
|
);
|
|
}
|
|
|
|
function azureRemoteMatches(
|
|
repository: ExternalVcsRepository,
|
|
remote: URL,
|
|
parts: string[],
|
|
): boolean {
|
|
return (
|
|
remote.hostname === "dev.azure.com" &&
|
|
parts.length === 4 &&
|
|
parts[1] === repository.provider_owner &&
|
|
parts[2] === "_git" &&
|
|
parts[3] === repository.provider_name
|
|
);
|
|
}
|
|
|
|
function resolveProvider(
|
|
repository: ExternalVcsRepository,
|
|
remote: URL,
|
|
): ExternalVcsProvider | null {
|
|
const provider = repository.provider.toLowerCase();
|
|
const parts = decodedRemoteParts(remote);
|
|
if (!parts || !repository.provider_owner || !repository.provider_name) return null;
|
|
|
|
if (provider === "github" && githubRemoteMatches(repository, remote, parts)) return "github";
|
|
if (provider === "gitlab" && gitlabRemoteMatches(repository, remote, parts)) return "gitlab";
|
|
if (provider === "azure_devops" && azureRemoteMatches(repository, remote, parts)) {
|
|
return "azure_devops";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function encodeRepositoryPath(owner: string, name: string): string {
|
|
return owner
|
|
.split("/")
|
|
.concat(name)
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/");
|
|
}
|
|
|
|
function buildFileURL(
|
|
provider: ExternalVcsProvider,
|
|
repository: ExternalVcsRepository,
|
|
remote: URL,
|
|
target: ResolvedTarget,
|
|
): string {
|
|
if (provider === "azure_devops") {
|
|
const url = new URL(remote.toString());
|
|
url.pathname = url.pathname.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
url.searchParams.set("path", `/${target.path}`);
|
|
url.searchParams.set("version", `GB${target.revision}`);
|
|
return url.toString();
|
|
}
|
|
const repositoryPath = encodeRepositoryPath(repository.provider_owner, repository.provider_name);
|
|
const route = provider === "gitlab" ? "-/blob" : "blob";
|
|
const filePath = target.path.split("/").map(encodeURIComponent).join("/");
|
|
return `${remote.origin}/${repositoryPath}/${route}/${encodeURIComponent(target.revision)}/${filePath}`;
|
|
}
|
|
|
|
export function resolveExternalVcsFileURL(
|
|
input: ExternalVcsFileURLInput,
|
|
): ExternalVcsFileURL | null {
|
|
const remote = parseRepositoryRemote(input.repository);
|
|
const provider = remote ? resolveProvider(input.repository, remote) : null;
|
|
const target = selectTarget(input);
|
|
if (
|
|
!remote ||
|
|
!provider ||
|
|
!target ||
|
|
!isSafeRepositoryPath(target.path) ||
|
|
!isSafeRef(target.revision)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
provider,
|
|
url: buildFileURL(provider, input.repository, remote, target),
|
|
path: target.path,
|
|
revision: target.revision,
|
|
};
|
|
}
|