| 1 | // Reads the plugin zip in apps/jetbrains/dist for Diffinery's server build (apps/diffinery/scripts/build-docs.mjs), |
| 2 | // which serves it at /-/jetbrains/. The zip is checked in because the server's build has no JDK, so this refuses one |
| 3 | // that doesn't match the sources: its version must be gradle.properties' pluginVersion, and the fingerprint of |
| 4 | // src/main built into it (see sourcesFingerprint in build.gradle.kts) must match the sources as they are now. |
| 5 | // Rebuild it with `./gradlew dist`. |
| 6 | import { createHash } from "node:crypto"; |
| 7 | import { readdir, readFile } from "node:fs/promises"; |
| 8 | import path from "node:path"; |
| 9 | import { fileURLToPath } from "node:url"; |
| 10 | import { inflateRawSync } from "node:zlib"; |
| 11 | |
| 12 | const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 13 | const sha256 = (b) => createHash("sha256").update(b).digest("hex"); |
| 14 | |
| 15 | /** The entries of a zip file, by name. Enough of the format for what Gradle writes: stored or deflated, no zip64. */ |
| 16 | export function unzip(buf) { |
| 17 | let eocd = -1; |
| 18 | for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) |
| 19 | if (buf.readUInt32LE(i) === 0x06054b50) { |
| 20 | eocd = i; |
| 21 | break; |
| 22 | } |
| 23 | if (eocd < 0) throw new Error("not a zip file"); |
| 24 | const count = buf.readUInt16LE(eocd + 10); |
| 25 | let p = buf.readUInt32LE(eocd + 16); |
| 26 | const entries = new Map(); |
| 27 | for (let n = 0; n < count; n++) { |
| 28 | if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error("bad zip central directory"); |
| 29 | const method = buf.readUInt16LE(p + 10); |
| 30 | const size = buf.readUInt32LE(p + 20); |
| 31 | const nameLen = buf.readUInt16LE(p + 28); |
| 32 | const local = buf.readUInt32LE(p + 42); |
| 33 | const name = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8"); |
| 34 | p += 46 + nameLen + buf.readUInt16LE(p + 30) + buf.readUInt16LE(p + 32); |
| 35 | if (name.endsWith("/")) continue; |
| 36 | const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); |
| 37 | const raw = buf.subarray(start, start + size); |
| 38 | if (method === 0) entries.set(name, raw); |
| 39 | else if (method === 8) entries.set(name, inflateRawSync(raw)); |
| 40 | else throw new Error(`${name}: zip method ${method} isn't supported`); |
| 41 | } |
| 42 | return entries; |
| 43 | } |
| 44 | |
| 45 | /** The same fingerprint of src/main as build.gradle.kts computes. */ |
| 46 | export async function sourcesFingerprint() { |
| 47 | const src = path.join(root, "src/main"); |
| 48 | const files = (await readdir(src, { recursive: true, withFileTypes: true })).filter((d) => d.isFile()).map((d) => path.relative(src, path.join(d.parentPath, d.name)).split(path.sep).join("/")); |
| 49 | files.sort(); |
| 50 | let listing = ""; |
| 51 | for (const f of files) listing += `${f} ${sha256(await readFile(path.join(src, f)))}\n`; |
| 52 | return sha256(listing); |
| 53 | } |
| 54 | |
| 55 | export async function readPlugin() { |
| 56 | const props = Object.fromEntries( |
| 57 | (await readFile(path.join(root, "gradle.properties"), "utf8")) |
| 58 | .split("\n") |
| 59 | .filter((l) => /^\w+=/.test(l)) |
| 60 | .map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1).trim()]), |
| 61 | ); |
| 62 | const version = props.pluginVersion; |
| 63 | const fileName = `diffinery-jetbrains-${version}.zip`; |
| 64 | const rebuild = "rebuild it with `./gradlew dist` in apps/jetbrains (see apps/jetbrains/README.md)"; |
| 65 | const inDist = (await readdir(path.join(root, "dist")).catch(() => [])).filter((f) => f.endsWith(".zip")); |
| 66 | if (!inDist.includes(fileName)) throw new Error(`apps/jetbrains/dist/${fileName} is missing: ${rebuild}`); |
| 67 | if (inDist.length > 1) throw new Error(`apps/jetbrains/dist has more than one zip (${inDist.join(", ")}): keep only ${fileName}`); |
| 68 | const zip = await readFile(path.join(root, "dist", fileName)); |
| 69 | const jarName = `diffinery-jetbrains/lib/diffinery-jetbrains-${version}.jar`; |
| 70 | const jarBytes = unzip(zip).get(jarName); |
| 71 | if (!jarBytes) throw new Error(`apps/jetbrains/dist/${fileName} has no ${jarName}: ${rebuild}`); |
| 72 | const jar = unzip(jarBytes); |
| 73 | const xml = jar.get("META-INF/plugin.xml")?.toString("utf8") ?? ""; |
| 74 | const built = jar.get("META-INF/diffinery-sources.sha256")?.toString("utf8").trim(); |
| 75 | if (built !== (await sourcesFingerprint())) throw new Error(`apps/jetbrains/dist/${fileName} was built from other sources than apps/jetbrains/src/main: ${rebuild}`); |
| 76 | const attr = (re) => xml.match(re)?.[1]; |
| 77 | const id = attr(/<id>([^<]+)<\/id>/); |
| 78 | const sinceBuild = attr(/since-build="([^"]+)"/); |
| 79 | if (id !== "com.diffinery" || attr(/<version>([^<]+)<\/version>/) !== version || sinceBuild !== props.sinceBuild) |
| 80 | throw new Error(`apps/jetbrains/dist/${fileName}: its plugin.xml doesn't match gradle.properties: ${rebuild}`); |
| 81 | return { id, version, sinceBuild, fileName, zip, description: attr(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/)?.trim() ?? "" }; |
| 82 | } |