392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
type ReleaseVerification = {
|
|
publish: {
|
|
provider: string;
|
|
owner: string;
|
|
repo: string;
|
|
};
|
|
latestArtifact: string;
|
|
missingArtifacts: string[];
|
|
};
|
|
|
|
type CommandResult = {
|
|
status: number | null;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
error?: Error;
|
|
};
|
|
|
|
type ArchiveVerification = {
|
|
verifiedArchives: string[];
|
|
};
|
|
|
|
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
|
|
const verifierUrl = "../scripts/verify_public_release.mjs";
|
|
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
|
|
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
|
|
verifyReleaseArchives: (
|
|
rootDir: string,
|
|
options: {
|
|
sevenZipPath: string;
|
|
runCommand: (command: string, args: string[]) => CommandResult;
|
|
}
|
|
) => ArchiveVerification;
|
|
};
|
|
const fixtureRoots: string[] = [];
|
|
const redistributionFiles = [
|
|
"LICENSE",
|
|
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
|
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
|
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
|
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
|
|
] as const;
|
|
|
|
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
|
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content);
|
|
}
|
|
|
|
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
|
for (const relativePath of redistributionFiles) {
|
|
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
|
let targetPath: string = relativePath;
|
|
if (packaged && relativePath === "LICENSE") {
|
|
targetPath = "win-unpacked/resources/LICENSE";
|
|
} else if (packaged) {
|
|
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
|
|
}
|
|
writeFile(rootDir, targetPath, content);
|
|
}
|
|
}
|
|
|
|
function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
|
for (const relativePath of redistributionFiles) {
|
|
if (path.basename(relativePath) === omittedName) {
|
|
continue;
|
|
}
|
|
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
|
const targetPath = relativePath === "LICENSE"
|
|
? "resources/LICENSE"
|
|
: `resources/app.asar.unpacked/${relativePath}`;
|
|
writeFile(outputDir, targetPath, content);
|
|
}
|
|
}
|
|
|
|
function createArchiveCommandRunner(omittedName = "") {
|
|
return (command: string, args: string[]): CommandResult => {
|
|
const archivePath = args[1] || "";
|
|
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
|
if (!outputArg) {
|
|
return { status: 2, stderr: "missing output directory" };
|
|
}
|
|
const outputDir = outputArg.slice(2);
|
|
if (archivePath.toLowerCase().endsWith(".exe")) {
|
|
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
|
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
|
writeArchivePayload(outputDir, omittedName);
|
|
}
|
|
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
|
};
|
|
}
|
|
|
|
function createReleaseFixture(): string {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
|
|
fixtureRoots.push(rootDir);
|
|
const setupPayload = Buffer.from("setup");
|
|
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
|
|
|
|
writeFile(rootDir, "package.json", `${JSON.stringify({
|
|
name: "real-debrid-downloader",
|
|
version: "1.7.233",
|
|
build: {
|
|
productName: "Real-Debrid-Downloader",
|
|
publish: {
|
|
provider: "github",
|
|
owner: "Sucukdeluxe",
|
|
repo: "multi-debrid-downloader"
|
|
},
|
|
files: [
|
|
"build/main/**/*",
|
|
"build/renderer/**/*",
|
|
"resources/extractor-jvm/**/*",
|
|
"LICENSE",
|
|
"package.json"
|
|
],
|
|
extraResources: [
|
|
{
|
|
from: "LICENSE",
|
|
to: "LICENSE"
|
|
}
|
|
],
|
|
nsis: {
|
|
artifactName: "${productName}-Setup-${version}.${ext}",
|
|
oneClick: false,
|
|
perMachine: false,
|
|
allowToChangeInstallationDirectory: true,
|
|
createDesktopShortcut: true
|
|
},
|
|
portable: {
|
|
artifactName: "${productName}-${version}-portable.${ext}"
|
|
}
|
|
}
|
|
}, null, 2)}\n`);
|
|
writeFile(
|
|
rootDir,
|
|
"latest.yml",
|
|
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
|
|
);
|
|
writeFile(
|
|
rootDir,
|
|
"win-unpacked/resources/app-update.yml",
|
|
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
|
|
);
|
|
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
|
|
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
|
|
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
|
writeRedistributionFiles(rootDir);
|
|
writeRedistributionFiles(rootDir, true);
|
|
|
|
return rootDir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const rootDir of fixtureRoots.splice(0)) {
|
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("public release metadata", () => {
|
|
it("accepts the canonical GitHub release metadata and artifacts", () => {
|
|
const rootDir = createReleaseFixture();
|
|
|
|
const result = verifyPublicRelease(rootDir);
|
|
|
|
expect(result.publish).toEqual({
|
|
provider: "github",
|
|
owner: "Sucukdeluxe",
|
|
repo: "multi-debrid-downloader"
|
|
});
|
|
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
|
|
expect(result.missingArtifacts).toEqual([]);
|
|
});
|
|
|
|
it("rejects a package configured for a different GitHub owner", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const packagePath = path.join(rootDir, "package.json");
|
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
packageJson.build.publish.owner = "DifferentOwner";
|
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
|
|
});
|
|
|
|
it("rejects a latest.yml path whose artifact does not exist", () => {
|
|
const rootDir = createReleaseFixture();
|
|
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
|
|
});
|
|
|
|
it("rejects syntactically invalid latest.yml", () => {
|
|
const rootDir = createReleaseFixture();
|
|
fs.writeFileSync(
|
|
path.join(rootDir, "latest.yml"),
|
|
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
|
|
);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
|
|
});
|
|
|
|
it("rejects a noncanonical files entry in latest.yml", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const latestPath = path.join(rootDir, "latest.yml");
|
|
const latest = fs.readFileSync(latestPath, "utf8").replace(
|
|
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
|
|
"url: Different-Setup-1.7.233.exe"
|
|
);
|
|
fs.writeFileSync(latestPath, latest);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
|
|
});
|
|
|
|
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const latestPath = path.join(rootDir, "latest.yml");
|
|
const latest = fs.readFileSync(latestPath, "utf8");
|
|
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
|
|
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
|
|
});
|
|
|
|
it("rejects a directory in place of an artifact file", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
|
fs.rmSync(setupPath);
|
|
fs.mkdirSync(setupPath);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
|
|
});
|
|
|
|
it("rejects an empty artifact file", () => {
|
|
const rootDir = createReleaseFixture();
|
|
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
|
|
});
|
|
|
|
it("rejects a release missing a declared redistribution license", () => {
|
|
const rootDir = createReleaseFixture();
|
|
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
|
|
});
|
|
|
|
it("rejects a modified official license text", () => {
|
|
const rootDir = createReleaseFixture();
|
|
fs.appendFileSync(
|
|
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
|
|
"modified"
|
|
);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
|
|
});
|
|
|
|
it("rejects swapped third-party license assignments", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
|
|
const notice = fs.readFileSync(noticePath, "utf8")
|
|
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
|
|
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
|
|
fs.writeFileSync(noticePath, notice);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
|
|
});
|
|
|
|
it("rejects a release whose unpacked application omits a license", () => {
|
|
const rootDir = createReleaseFixture();
|
|
fs.rmSync(path.join(
|
|
rootDir,
|
|
"win-unpacked",
|
|
"resources",
|
|
"app.asar.unpacked",
|
|
"resources",
|
|
"extractor-jvm",
|
|
"licenses",
|
|
"Apache-2.0.txt"
|
|
));
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
|
|
});
|
|
|
|
it("rejects a symlink in place of a release artifact", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
|
const targetPath = path.join(rootDir, "setup-target.exe");
|
|
fs.renameSync(setupPath, targetPath);
|
|
fs.symlinkSync(targetPath, setupPath, "file");
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
|
});
|
|
|
|
it("rejects a symlink in place of an official license", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
|
|
const targetPath = path.join(rootDir, "Apache-target.txt");
|
|
fs.renameSync(licensePath, targetPath);
|
|
fs.symlinkSync(targetPath, licensePath, "file");
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
|
});
|
|
|
|
it("rejects build metadata that omits the project license", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const packagePath = path.join(rootDir, "package.json");
|
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
|
});
|
|
|
|
it("rejects build metadata that does not copy the project license into resources", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const packagePath = path.join(rootDir, "package.json");
|
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
delete packageJson.build.extraResources;
|
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
|
|
});
|
|
|
|
it("rejects incomplete third-party redistribution notices", () => {
|
|
const rootDir = createReleaseFixture();
|
|
fs.writeFileSync(
|
|
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
|
|
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
|
|
);
|
|
|
|
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
|
|
});
|
|
|
|
it("returns a nonzero CLI status for invalid release metadata", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const packagePath = path.join(rootDir, "package.json");
|
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
packageJson.build.publish.owner = "DifferentOwner";
|
|
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
|
|
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
|
|
encoding: "utf8"
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toMatch(/owner/i);
|
|
});
|
|
|
|
it("recursively verifies redistribution files inside setup and portable archives", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const result = verifyReleaseArchives(rootDir, {
|
|
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
|
runCommand: createArchiveCommandRunner()
|
|
});
|
|
|
|
expect(result.verifiedArchives).toEqual([
|
|
"Real-Debrid-Downloader-Setup-1.7.233.exe",
|
|
"Real-Debrid-Downloader-1.7.233-portable.exe"
|
|
]);
|
|
});
|
|
|
|
it("rejects an archive whose nested application payload omits a license", () => {
|
|
const rootDir = createReleaseFixture();
|
|
|
|
expect(() => verifyReleaseArchives(rootDir, {
|
|
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
|
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
|
|
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
|
});
|
|
|
|
it("exposes archive verification as a nonzero CLI gate", () => {
|
|
const rootDir = createReleaseFixture();
|
|
const result = spawnSync(process.execPath, [
|
|
verifierPath,
|
|
rootDir,
|
|
"--verify-archives",
|
|
"--seven-zip",
|
|
path.join(rootDir, "missing-7z.exe")
|
|
], {
|
|
encoding: "utf8"
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
|
|
});
|
|
});
|