Skip to content
Source
Sign inSign up

cloneUrl.ts

92 lines3.6 KB

1import { isWindowsDrivePath } from "./path.ts";
2​
3export type CloneProtocol = "ssh" | "https";
4​
5const SCP_LIKE_URL = /^(?:[^@/\s]+@)?(\[[^\]@/\s]+\]|[^:/\s[\]]+):(?!\/)(.+)$/;
6// Userinfo may contain `@`; the last one before the first slash separates the host.
7const SSH_URL = /^ssh:\/\/(?:[^/\s]*@)?(\[[^\]@/\s]+\]|[^@:/\s]+)(:\d+)?\/(.+)$/;
8const HTTPS_URL = /^https?:\/\/(?:[^/\s]*@)?(\[[^\]@/\s]+\]|[^@:/\s]+)(:\d+)?\/(.+)$/;
9​
10function parseCloneUrl(url: string): {
11 protocol: CloneProtocol;
12 host: string;
13 path: string;
14 customPort: boolean;
15 scp: boolean;
16} | null {
17 const trimmed = url.trim();
18 const ssh = SSH_URL.exec(trimmed);
19 if (ssh)
20 return { protocol: "ssh", host: ssh[1]!, path: ssh[3]!, customPort: !!ssh[2], scp: false };
21 const https = HTTPS_URL.exec(trimmed);
22 if (https)
23 return {
24 protocol: "https",
25 host: https[1]!,
26 path: https[3]!,
27 customPort: !!https[2],
28 scp: false,
29 };
30 const scp = SCP_LIKE_URL.exec(trimmed);
31 if (scp) return { protocol: "ssh", host: scp[1]!, path: scp[2]!, customPort: false, scp: true };
32 return null;
34​
35export function cloneUrlProtocol(url: string): CloneProtocol | null {
36 return parseCloneUrl(url)?.protocol ?? null;
38​
39/**
40 * Rewrites a clone URL to the other protocol, because the target machine may
41 * only have SSH keys or only an HTTPS credential helper. Returns null when the
42 * URL cannot be rewritten safely, such as a custom port.
43 */
44export function cloneUrlForProtocol(url: string, protocol: CloneProtocol): string | null {
45 const parsed = parseCloneUrl(url);
46 if (!parsed) return null;
47 if (parsed.protocol === protocol) return url.trim();
48 if (parsed.customPort) return null;
49 // Azure's transports use different hosts and put the repository marker in
50 // different positions; changing only the scheme produces an invalid clone URL.
51 if (parsed.protocol === "ssh" && parsed.host.toLowerCase() === "ssh.dev.azure.com") {
52 const azure = /^v3\/([^/?#]+)\/([^/?#]+)\/([^/?#]+)$/.exec(parsed.path);
53 return azure ? `https://dev.azure.com/${azure[1]}/${azure[2]}/_git/${azure[3]}` : null;
54 }
55 if (parsed.protocol === "https" && parsed.host.toLowerCase() === "dev.azure.com") {
56 const azure = /^([^/?#]+)\/([^/?#]+)\/_git\/([^/?#]+)$/.exec(parsed.path);
57 return azure ? `git@ssh.dev.azure.com:v3/${azure[1]}/${azure[2]}/${azure[3]}` : null;
58 }
59 // SCP paths are literal; URI paths use percent escapes.
60 try {
61 if (protocol === "ssh") return `git@${parsed.host}:${decodeURIComponent(parsed.path)}`;
62 const path = parsed.scp
63 ? parsed.path.split("/").map(encodeURIComponent).join("/")
64 : parsed.path;
65 return `https://${parsed.host}/${path}`;
66 } catch {
67 return null;
68 }
70​
71const UNSAFE_SEGMENT = /^\.{1,2}$|[\\:*?"<>|\p{Cc}]|[ .]$/u;
72const WINDOWS_DEVICE_NAME =
73 /^(?:con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])(?: *\.| *$)/i;
74​
75/**
76 * The `<host>/<owner>/<repo>` folders ghq clones a URL into, or null for local paths and
77 * anything with a folder that could leave the root.
78 */
79export function cloneUrlRepositoryPath(url: string): string | null {
80 if (isWindowsDrivePath(url.trim())) return null;
81 const parsed = parseCloneUrl(url);
82 if (!parsed) return null;
83 const segments = [parsed.host, ...parsed.path.split(/[?#]/)[0]!.split("/").filter(Boolean)];
84 const last = segments.at(-1)!;
85 if (last.endsWith(".git")) segments[segments.length - 1] = last.slice(0, -".git".length);
86 if (
87 segments.length < 2 ||
88 segments.some((s) => s === "" || UNSAFE_SEGMENT.test(s) || WINDOWS_DEVICE_NAME.test(s))
89 )
90 return null;
91 return segments.join("/");