| |
| |
| |
| |
| |
| import hostedGitInfo from "hosted-git-info"; |
|
|
| |
| export type GitSource = { |
| |
| type: "git"; |
| |
| host: string; |
| |
| path: string; |
| }; |
|
|
| function stripRef(url: string): string { |
| const protocolIndex = url.indexOf("://"); |
| |
| const pathStart = url.startsWith("git@") |
| ? url.indexOf(":") + 1 |
| : url.indexOf("/", protocolIndex < 0 ? 0 : protocolIndex + 3) + 1; |
| if (pathStart <= 0) { |
| return url; |
| } |
| const suffixOffset = url.slice(pathStart).search(/[?#]/); |
| const pathEnd = suffixOffset < 0 ? url.length : pathStart + suffixOffset; |
| let refSeparator = url.indexOf("@", pathStart); |
| if (refSeparator === pathStart) { |
| refSeparator = url.indexOf("@", pathStart + 1); |
| } |
| if (refSeparator <= pathStart || refSeparator === pathEnd - 1 || refSeparator >= pathEnd) { |
| return url; |
| } |
| return protocolIndex < 0 |
| ? url.slice(0, refSeparator) |
| : url.slice(0, refSeparator) + url.slice(pathEnd); |
| } |
|
|
| function hasUnsafePathSegments(url: string): boolean { |
| const path = url.split(/[?#]/, 1)[0] ?? ""; |
| return path.includes("\\") || /(?:^|\/)(?:\.|%2e){1,2}(?:\/|$)/i.test(path); |
| } |
|
|
| function parseGenericGitUrl(url: string): GitSource | null { |
| let host; |
| let path; |
|
|
| const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/); |
| if (scpLikeMatch) { |
| host = scpLikeMatch[1] ?? ""; |
| path = scpLikeMatch[2] ?? ""; |
| } else if (/^(?:https?|ssh|git):\/\//.test(url)) { |
| try { |
| const parsed = new URL(url); |
| host = parsed.hostname; |
| path = parsed.pathname.replace(/^\/+/, ""); |
| } catch { |
| return null; |
| } |
| } else { |
| const slashIndex = url.indexOf("/"); |
| if (slashIndex < 0) { |
| return null; |
| } |
| host = url.slice(0, slashIndex); |
| path = url.slice(slashIndex + 1); |
| if (!host.includes(".") && host !== "localhost") { |
| return null; |
| } |
| } |
|
|
| const normalizedPath = normalizeGitPath(path); |
| if (!isSafeGitHost(host) || !normalizedPath) { |
| return null; |
| } |
|
|
| return { |
| type: "git", |
| host, |
| path: normalizedPath, |
| }; |
| } |
|
|
| function isSafeGitHost(host: string): boolean { |
| return ( |
| Boolean(host) && !host.includes("/") && !host.includes("\\") && host !== "." && host !== ".." |
| ); |
| } |
|
|
| function normalizeGitPath(path: string): string | null { |
| const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, ""); |
| const segments = normalizedPath.split("/"); |
| if (segments.length < 2) { |
| return null; |
| } |
| if ( |
| segments.some( |
| (segment) => !segment || segment === "." || segment === ".." || segment.includes("\\"), |
| ) |
| ) { |
| return null; |
| } |
| return segments.join("/"); |
| } |
|
|
| function parseHostedGitUrl(url: string): GitSource | null { |
| const candidates = [url]; |
| if (!url.includes("://") && !url.startsWith("git@")) { |
| candidates.push(`https://${url}`); |
| } |
|
|
| for (const candidate of candidates) { |
| const info = hostedGitInfo.fromUrl(candidate); |
| if (!info) { |
| continue; |
| } |
| const host = info.domain || ""; |
| const path = normalizeGitPath(`${info.user}/${info.project}`); |
| if (isSafeGitHost(host) && path) { |
| return { type: "git", host, path }; |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function parseGitUrl(source: string): GitSource | null { |
| const trimmed = source.trim(); |
| const hasGitPrefix = trimmed.startsWith("git:"); |
| const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed; |
|
|
| if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) { |
| return null; |
| } |
|
|
| const urlWithoutRef = stripRef(url); |
| if (hasUnsafePathSegments(urlWithoutRef)) { |
| return null; |
| } |
| return parseHostedGitUrl(urlWithoutRef) ?? parseGenericGitUrl(urlWithoutRef); |
| } |
|
|