| 1 | // Pure helpers, without the vscode module, so the unit tests can run them under Node. |
| 2 | import { createHash, randomBytes } from "node:crypto"; |
| 3 | |
| 4 | export const EXTENSION_ID = "codician.diffinery"; |
| 5 | /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */ |
| 6 | export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"]; |
| 7 | export const DEFAULT_SERVER = "https://diffinery.com"; |
| 8 | |
| 9 | /** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */ |
| 10 | export function normalizeServer(raw: string | undefined): string { |
| 11 | const s = (raw ?? "").trim() || DEFAULT_SERVER; |
| 12 | let u: URL; |
| 13 | try { |
| 14 | u = new URL(s.includes("://") ? s : `https://${s}`); |
| 15 | } catch { |
| 16 | throw new Error(`The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.`); |
| 17 | } |
| 18 | const loopback = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(u.hostname); |
| 19 | if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) throw new Error(`The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`); |
| 20 | return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}`; |
| 21 | } |
| 22 | |
| 23 | /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */ |
| 24 | export const clientId = (server: string) => `${server}/oauth/clients/editor.json`; |
| 25 | |
| 26 | /** Where the server sends the browser back after approval: the editor's own scheme when the server knows it, else null (use loopback). */ |
| 27 | export function editorRedirect(scheme: string): string | null { |
| 28 | return EDITOR_SCHEMES.includes(scheme) ? `${scheme}://${EXTENSION_ID}/oauth` : null; |
| 29 | } |
| 30 | |
| 31 | export const b64url = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url"); |
| 32 | |
| 33 | export function pkce(): { verifier: string; challenge: string; state: string } { |
| 34 | const verifier = b64url(randomBytes(32)); |
| 35 | return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()), state: b64url(randomBytes(16)) }; |
| 36 | } |
| 37 | |
| 38 | export function authorizeUrl(server: string, redirectUri: string, challenge: string, state: string): string { |
| 39 | const q = new URLSearchParams({ response_type: "code", client_id: clientId(server), redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: "S256", state, scope: "write" }); |
| 40 | return `${server}/oauth/authorize?${q}`; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * What a callback from the server carries: the code, or why there isn't one. Checks the state (it must be the one this |
| 45 | * sign-in sent) and, when present, the issuer (RFC 9207), so a code from another server or another attempt is refused. |
| 46 | */ |
| 47 | export function readCallback(query: string, expect: { state: string; server: string }): { code: string } | { error: string } { |
| 48 | const q = new URLSearchParams(query); |
| 49 | if (q.get("state") !== expect.state) return { error: "The sign-in response didn't match this sign-in. Try again." }; |
| 50 | const iss = q.get("iss"); |
| 51 | if (iss && iss.replace(/\/+$/, "") !== expect.server) return { error: `The sign-in response came from ${iss}, not ${expect.server}.` }; |
| 52 | const err = q.get("error"); |
| 53 | if (err) return { error: err === "access_denied" ? "Sign-in was cancelled in the browser." : `Diffinery refused the sign-in: ${q.get("error_description") || err}` }; |
| 54 | const code = q.get("code"); |
| 55 | return code ? { code } : { error: "The sign-in response had no code. Try again." }; |
| 56 | } |
| 57 | |
| 58 | export interface RepoRef { |
| 59 | owner: string; |
| 60 | repo: string; |
| 61 | } |
| 62 | |
| 63 | const NAME = /^[A-Za-z0-9._-]+$/; |
| 64 | |
| 65 | /** owner/repo from "owner/repo", or from a web or clone URL on this server. Null for anything else. */ |
| 66 | export function repoRef(server: string, input: string): RepoRef | null { |
| 67 | const s = input.trim(); |
| 68 | let path: string; |
| 69 | if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) { |
| 70 | let u: URL; |
| 71 | try { |
| 72 | u = new URL(s); |
| 73 | } catch { |
| 74 | return null; |
| 75 | } |
| 76 | const base = new URL(server); |
| 77 | if (u.host.toLowerCase() !== base.host.toLowerCase()) return null; |
| 78 | path = u.pathname.slice(base.pathname.replace(/\/+$/, "").length); |
| 79 | } else { |
| 80 | path = s; |
| 81 | } |
| 82 | const parts = path.replace(/^\/+|\/+$/g, "").split("/"); |
| 83 | if (parts.length < 2) return null; |
| 84 | const owner = parts[0]!; |
| 85 | const repo = parts[1]!.replace(/\.git$/, ""); |
| 86 | if (!NAME.test(owner) || !NAME.test(repo) || repo === "." || repo === "..") return null; |
| 87 | // A web URL deeper in the repository (/owner/repo/tree/main/src) still names the repository; a bare "a/b/c" doesn't. |
| 88 | if (parts.length > 2 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return null; |
| 89 | return { owner, repo }; |
| 90 | } |
| 91 | |
| 92 | export interface RepoJson { |
| 93 | full_name: string; |
| 94 | name: string; |
| 95 | owner: { login: string }; |
| 96 | description: string | null; |
| 97 | private: boolean; |
| 98 | visibility?: string; |
| 99 | archived?: boolean; |
| 100 | fork?: boolean; |
| 101 | clone_url: string; |
| 102 | html_url: string; |
| 103 | default_branch?: string; |
| 104 | pushed_at?: string | null; |
| 105 | updated_at?: string | null; |
| 106 | } |
| 107 | |
| 108 | /** Repositories that match what someone typed: every word in the full name or description, ignoring case. */ |
| 109 | export function matches(r: RepoJson, query: string): boolean { |
| 110 | const words = query.toLowerCase().split(/\s+/).filter(Boolean); |
| 111 | const hay = `${r.full_name} ${r.description ?? ""}`.toLowerCase(); |
| 112 | return words.every((w) => hay.includes(w)); |
| 113 | } |
| 114 | |
| 115 | /** Merges lists of repositories, first occurrence wins, in order. */ |
| 116 | export function dedupe(...lists: RepoJson[][]): RepoJson[] { |
| 117 | const seen = new Set<string>(); |
| 118 | const out: RepoJson[] = []; |
| 119 | for (const list of lists) |
| 120 | for (const r of list) { |
| 121 | const k = r.full_name.toLowerCase(); |
| 122 | if (!seen.has(k)) { |
| 123 | seen.add(k); |
| 124 | out.push(r); |
| 125 | } |
| 126 | } |
| 127 | return out; |
| 128 | } |
| 129 | |
| 130 | /** The next page from a Link header (RFC 8288), as GitHub-style APIs send it. */ |
| 131 | export function nextLink(link: string | null | undefined): string | null { |
| 132 | if (!link) return null; |
| 133 | for (const part of link.split(",")) { |
| 134 | const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part); |
| 135 | if (m) return m[1]!; |
| 136 | } |
| 137 | return null; |
| 138 | } |
| 139 | |
| 140 | /** -1, 0 or 1, comparing dotted version numbers (a pre-release suffix sorts before the release). */ |
| 141 | export function compareVersions(a: string, b: string): number { |
| 142 | const parse = (v: string) => { |
| 143 | const [core, pre] = v.replace(/^v/, "").split("-", 2); |
| 144 | return { nums: core!.split(".").map((n) => Number.parseInt(n, 10) || 0), pre: pre ?? null }; |
| 145 | }; |
| 146 | const x = parse(a); |
| 147 | const y = parse(b); |
| 148 | for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) { |
| 149 | const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0); |
| 150 | if (d) return d < 0 ? -1 : 1; |
| 151 | } |
| 152 | if (x.pre === y.pre) return 0; |
| 153 | if (x.pre === null) return 1; |
| 154 | if (y.pre === null) return -1; |
| 155 | return x.pre < y.pre ? -1 : 1; |
| 156 | } |
| 157 | |
| 158 | /** The lines `git credential approve|reject` reads for a token on the server's host. */ |
| 159 | export function credentialInput(server: string, username: string, password: string, expiresAt?: number): string { |
| 160 | const u = new URL(server); |
| 161 | const lines = [`protocol=${u.protocol.replace(/:$/, "")}`, `host=${u.host}`, `username=${username}`, `password=${password}`]; |
| 162 | // Git 2.41+ passes this on, and helpers that keep it (Git Credential Manager, osxkeychain, libsecret, cache) stop |
| 163 | // handing out the token once it has expired, so Git asks the editor for a fresh one instead of failing. |
| 164 | if (expiresAt) lines.push(`password_expiry_utc=${Math.floor(expiresAt / 1000)}`); |
| 165 | if (/[\n\0]/.test(lines.join(""))) throw new Error("credential contains a newline"); |
| 166 | return `${lines.join("\n")}\n\n`; |
| 167 | } |
| 168 | |
| 169 | /** A repository name Diffinery accepts, from a folder name: unsupported characters become "-". */ |
| 170 | export function repoNameFrom(folder: string): string { |
| 171 | const n = folder |
| 172 | .trim() |
| 173 | .replace(/[^A-Za-z0-9._-]+/g, "-") |
| 174 | .replace(/^[-.]+|[-.]+$/g, "") |
| 175 | .slice(0, 100); |
| 176 | return n || "repository"; |
| 177 | } |