Files
Jeen 4b8a314d48 Remove Android and APK from legacyNeedles array
Removed legacy needles related to Android and APK.
2026-06-21 13:28:13 +02:00

605 lines
18 KiB
JavaScript

#!/usr/bin/env node
import Ajv from "ajv";
import { execFile } from "node:child_process";
import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, extname, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
import { parseOptions, setOptionFlag, setOptionValue } from "./cli.js";
import {
bundleIdentifier,
canonicalBaseUrl,
canonicalSourceUrl,
repositoryRoot,
sourceIdentifier,
} from "./constants.js";
import { ipaFileManifest } from "./ipa-metadata.js";
const execFileAsync = promisify(execFile);
const schemaUrl = "https://raw.githubusercontent.com/SideStore/sidestore-source-types/main/schema.json";
const generatorPath = resolve(repositoryRoot, "scripts/generate-source.js");
const defaults = {
sourcePath: "apps.json",
checksumPath: "checksums.json",
ipaDirectory: "ipas",
offlineFallback: true,
legacyPurge: true,
};
class SourceValidationError extends Error {
constructor(messages) {
super(messages.join("\n"));
this.name = "SourceValidationError";
}
}
class ValidationArgumentError extends Error {
constructor(message) {
super(message);
this.name = "ValidationArgumentError";
}
}
const parseArguments = (cliArguments) => parseOptions(
cliArguments,
defaults,
{
"--source": setOptionValue("sourcePath"),
"--checksums": setOptionValue("checksumPath"),
"--ipa-dir": setOptionValue("ipaDirectory"),
"--skip-offline-fallback": setOptionFlag("offlineFallback", false),
"--skip-legacy-purge": setOptionFlag("legacyPurge", false),
},
(message) => new ValidationArgumentError(message),
);
const jsonDocument = async (jsonPath) => {
const jsonText = await readFile(jsonPath, "utf8");
return JSON.parse(jsonText);
};
const fileBuffer = async (filePath) => readFile(filePath);
const relativePath = (entryPath) => relative(repositoryRoot, entryPath).split(sep).join("/");
const filesMatch = async (leftPath, rightPath) => {
try {
const [leftBuffer, rightBuffer] = await Promise.all([
fileBuffer(leftPath),
fileBuffer(rightPath),
]);
return leftBuffer.equals(rightBuffer);
} catch (filesystemError) {
if (filesystemError?.code === "ENOENT") {
return false;
}
throw filesystemError;
}
};
const assertFileExists = async (filePath, label, errors) => {
try {
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
errors.push(`${label} must be a file.`);
return null;
}
return fileStats;
} catch (filesystemError) {
if (filesystemError?.code === "ENOENT") {
errors.push(`${label} is missing.`);
return null;
}
throw filesystemError;
}
};
const canonicalOrigin = new URL(canonicalBaseUrl).origin;
const legacyHost = "j1coding.github.io";
const legacyPathPrefix = "/Armsx2-Repo";
const parsedUrl = (urlValue) => {
try {
return new URL(urlValue);
} catch {
return null;
}
};
const isCanonicalPublicUrl = (urlValue) => {
const url = parsedUrl(urlValue);
return Boolean(url && url.origin === canonicalOrigin);
};
const containsLegacySourceUrl = (payload) => {
const payloadText = JSON.stringify(payload);
return payloadText.includes(legacyHost) && payloadText.includes(legacyPathPrefix);
};
const urlPathToRepositoryPath = (urlValue) => {
const url = parsedUrl(urlValue);
if (!url || url.origin !== canonicalOrigin) {
return null;
}
const pathSegments = url.pathname
.split("/")
.filter(Boolean)
.map((pathSegment) => decodeURIComponent(pathSegment));
if (pathSegments.some((pathSegment) => pathSegment === "." || pathSegment === "..")) {
return null;
}
return resolve(repositoryRoot, ...pathSegments);
};
const sideStoreSchema = async () => {
const schemaResponse = await fetch(schemaUrl, {
headers: {
Accept: "application/schema+json, application/json",
"User-Agent": "J1coding-ARMSX2-Source-Validator/2.0",
},
});
if (!schemaResponse.ok) {
throw new SourceValidationError([`SideStore schema fetch failed: ${schemaResponse.status}`]);
}
return schemaResponse.json();
};
const validateAgainstSideStoreSchema = async (sourceJson) => {
const schemaDocument = await sideStoreSchema();
const schemaValidator = new Ajv({ allErrors: true, strict: true });
const validateSource = schemaValidator.compile(schemaDocument);
if (validateSource(sourceJson)) {
return [];
}
return validateSource.errors.map((schemaError) => {
const schemaPath = schemaError.instancePath || "$";
return `${schemaPath} ${schemaError.message}`;
});
};
const nestedKeyPaths = (unknownPayload, forbiddenKey, currentPath = "$") => {
if (Array.isArray(unknownPayload)) {
return unknownPayload.flatMap((nestedPayload, nestedIndex) =>
nestedKeyPaths(nestedPayload, forbiddenKey, `${currentPath}[${nestedIndex}]`),
);
}
if (!unknownPayload || typeof unknownPayload !== "object") {
return [];
}
return Object.entries(unknownPayload).flatMap(([payloadKey, nestedPayload]) => {
const matchedPath = payloadKey === forbiddenKey ? [`${currentPath}.${payloadKey}`] : [];
return [...matchedPath, ...nestedKeyPaths(nestedPayload, forbiddenKey, `${currentPath}.${payloadKey}`)];
});
};
const validateStrictSourceShape = (sourceJson) => {
const errors = [];
if (sourceJson.identifier !== sourceIdentifier) {
errors.push(`apps.json identifier must be ${sourceIdentifier}.`);
}
if (sourceJson.sourceURL !== canonicalSourceUrl) {
errors.push(`apps.json sourceURL must be ${canonicalSourceUrl}.`);
}
if (containsLegacySourceUrl(sourceJson)) {
errors.push("apps.json must not contain the old GitHub Pages source URL.");
}
const forbiddenSourceKeys = ["buildVersion", "sha256", "appPermissions", "marketplaceID"];
for (const forbiddenKey of forbiddenSourceKeys) {
const matchingPaths = nestedKeyPaths(sourceJson, forbiddenKey);
if (matchingPaths.length > 0) {
errors.push(`apps.json must not contain ${forbiddenKey}: ${matchingPaths.join(", ")}`);
}
}
for (const [appIndex, sourceApp] of sourceJson.apps?.entries?.() ?? []) {
if (sourceApp.bundleIdentifier !== bundleIdentifier) {
errors.push(`apps[${appIndex}].bundleIdentifier must be ${bundleIdentifier}.`);
}
if (!isCanonicalPublicUrl(sourceApp.iconURL)) {
errors.push(`apps[${appIndex}].iconURL must use ${canonicalBaseUrl}.`);
}
if (!Array.isArray(sourceApp.screenshotURLs) || sourceApp.screenshotURLs.length === 0) {
errors.push(`apps[${appIndex}].screenshotURLs must contain at least one screenshot URL.`);
} else {
for (const [screenshotIndex, screenshotURL] of sourceApp.screenshotURLs.entries()) {
const parsedScreenshotURL = parsedUrl(screenshotURL);
if (!parsedScreenshotURL) {
errors.push(`apps[${appIndex}].screenshotURLs[${screenshotIndex}] must be an absolute URL.`);
continue;
}
if (parsedScreenshotURL.protocol !== "https:") {
errors.push(`apps[${appIndex}].screenshotURLs[${screenshotIndex}] must use HTTPS.`);
}
if (parsedScreenshotURL.origin !== canonicalOrigin) {
errors.push(`apps[${appIndex}].screenshotURLs[${screenshotIndex}] must use ${canonicalBaseUrl}.`);
}
}
}
for (const [versionIndex, sourceVersion] of sourceApp.versions?.entries?.() ?? []) {
if (!isCanonicalPublicUrl(sourceVersion.downloadURL)) {
errors.push(`apps[${appIndex}].versions[${versionIndex}].downloadURL must use ${canonicalBaseUrl}.`);
}
}
}
return errors;
};
const validateChecksumManifest = (sourceJson, checksumJson) => {
const errors = [];
if (checksumJson.sourceIdentifier !== sourceIdentifier) {
errors.push(`checksums.json sourceIdentifier must be ${sourceIdentifier}.`);
}
if (checksumJson.sourceURL !== sourceJson.sourceURL) {
errors.push("checksums.json sourceURL must match apps.json sourceURL.");
}
if (checksumJson.sourceURL !== canonicalSourceUrl) {
errors.push(`checksums.json sourceURL must be ${canonicalSourceUrl}.`);
}
if (containsLegacySourceUrl(checksumJson)) {
errors.push("checksums.json must not contain the old GitHub Pages source URL.");
}
if (!Array.isArray(checksumJson.files)) {
errors.push("checksums.json files must be an array.");
return errors;
}
const sourceDownloadURLs = new Set(
(sourceJson.apps ?? []).flatMap((sourceApp) =>
(sourceApp.versions ?? []).map((sourceVersion) => sourceVersion.downloadURL),
),
);
for (const [fileIndex, checksumEntry] of checksumJson.files.entries()) {
const checksumPath = `files[${fileIndex}]`;
if (checksumEntry.bundleIdentifier !== bundleIdentifier) {
errors.push(`${checksumPath}.bundleIdentifier must be ${bundleIdentifier}.`);
}
if (!/^[0-9a-f]{64}$/u.test(checksumEntry.sha256 ?? "")) {
errors.push(`${checksumPath}.sha256 must be a lowercase SHA-256 hex digest.`);
}
if (!Number.isSafeInteger(checksumEntry.size) || checksumEntry.size <= 0) {
errors.push(`${checksumPath}.size must be a positive integer.`);
}
if (typeof checksumEntry.buildVersion !== "string" || checksumEntry.buildVersion.length === 0) {
errors.push(`${checksumPath}.buildVersion must be a non-empty string.`);
}
if (!sourceDownloadURLs.has(checksumEntry.downloadURL)) {
errors.push(`${checksumPath}.downloadURL is absent from public/apps.json.`);
}
if (!isCanonicalPublicUrl(checksumEntry.downloadURL)) {
errors.push(`${checksumPath}.downloadURL must use ${canonicalBaseUrl}.`);
}
}
return errors;
};
const matchingSourceVersions = (sourceJson) =>
(sourceJson.apps ?? []).flatMap((sourceApp) =>
(sourceApp.versions ?? []).map((sourceVersion) => ({
app: sourceApp,
version: sourceVersion,
})),
);
const validateGeneratedMirrors = async (validationOptions) => {
const errors = [];
if (validationOptions.sourcePath === defaults.sourcePath) {
const rootSourcePath = resolve(repositoryRoot, validationOptions.sourcePath);
const publicSourcePath = resolve(repositoryRoot, "public/apps.json");
if (!await filesMatch(rootSourcePath, publicSourcePath)) {
errors.push("public/apps.json must match apps.json.");
}
}
if (validationOptions.checksumPath === defaults.checksumPath) {
const rootChecksumsPath = resolve(repositoryRoot, validationOptions.checksumPath);
const publicChecksumsPath = resolve(repositoryRoot, "public/checksums.json");
if (!await filesMatch(rootChecksumsPath, publicChecksumsPath)) {
errors.push("public/checksums.json must match checksums.json.");
}
}
return errors;
};
const validateLocalAssets = async (sourceJson) => {
const errors = [];
const assetUrls = (sourceJson.apps ?? []).flatMap((sourceApp) => [
sourceApp.iconURL,
...(sourceApp.screenshotURLs ?? []),
]).filter(Boolean);
for (const assetUrl of assetUrls) {
const assetPath = urlPathToRepositoryPath(assetUrl);
if (!assetPath) {
errors.push(`${assetUrl} does not map to a local asset path.`);
continue;
}
const assetLabel = relativePath(assetPath);
await assertFileExists(assetPath, assetLabel, errors);
const publicAssetPath = resolve(repositoryRoot, "public", assetLabel);
await assertFileExists(publicAssetPath, `public/${assetLabel}`, errors);
}
return errors;
};
const validateLocalIpas = async (sourceJson, checksumJson, validationOptions) => {
const errors = [];
const ipaDirectory = resolve(repositoryRoot, validationOptions.ipaDirectory);
const sourceVersionsByDownloadURL = new Map(
matchingSourceVersions(sourceJson).map(({ version }) => [version.downloadURL, version]),
);
for (const [fileIndex, checksumEntry] of (checksumJson.files ?? []).entries()) {
const checksumPath = `files[${fileIndex}]`;
const sourceVersion = sourceVersionsByDownloadURL.get(checksumEntry.downloadURL);
const ipaPath = resolve(ipaDirectory, basename(checksumEntry.fileName ?? ""));
const fileStats = await assertFileExists(ipaPath, `${checksumPath}.fileName local IPA`, errors);
if (!fileStats) {
continue;
}
let manifest;
try {
manifest = await ipaFileManifest(ipaPath, { baseUrl: canonicalBaseUrl });
} catch (metadataError) {
const message = metadataError instanceof Error ? metadataError.message : String(metadataError);
errors.push(`${checksumPath}.fileName could not be read as a valid IPA: ${message}`);
continue;
}
if (fileStats.size !== checksumEntry.size) {
errors.push(`${checksumPath}.size must match ${relativePath(ipaPath)} (${fileStats.size}).`);
}
if (manifest.sha256 !== checksumEntry.sha256) {
errors.push(`${checksumPath}.sha256 must match ${relativePath(ipaPath)}.`);
}
if (manifest.version !== checksumEntry.version) {
errors.push(`${checksumPath}.version must match the local IPA version ${manifest.version}.`);
}
if (manifest.buildVersion !== checksumEntry.buildVersion) {
errors.push(`${checksumPath}.buildVersion must match the local IPA build ${manifest.buildVersion}.`);
}
if (sourceVersion && sourceVersion.version !== manifest.version) {
errors.push(`${checksumPath}.version must match apps.json version ${sourceVersion.version}.`);
}
if (sourceVersion && sourceVersion.size !== fileStats.size) {
errors.push(`${checksumPath}.size must match apps.json size ${sourceVersion.size}.`);
}
}
return errors;
};
const textExtensions = new Set([
".html",
".js",
".jsx",
".json",
".md",
".mjs",
".ts",
".tsx",
".txt",
".yaml",
".yml",
]);
const skippedDirectories = new Set([".git", "node_modules", "dist", ".vite"]);
const excludedLegacyScanFiles = new Set(["scripts/validate-source.js"]);
const legacyScanRoots = [
".github",
"README.md",
"index.html",
"metadata",
"scripts",
"package.json",
"apps.json",
"checksums.json",
"public/apps.json",
"public/checksums.json",
];
const relativeRepositoryPath = relativePath;
const isTextFile = (entryPath, entryStats) =>
entryStats.size <= 1024 * 1024
&& (textExtensions.has(extname(entryPath).toLowerCase()) || relativeRepositoryPath(entryPath) === ".gitignore");
const discoverTextFiles = async (entryPath) => {
const entryRelativePath = relativeRepositoryPath(entryPath);
if (excludedLegacyScanFiles.has(entryRelativePath)) {
return [];
}
const entryStats = await stat(entryPath);
if (entryStats.isFile()) {
return isTextFile(entryPath, entryStats) ? [entryPath] : [];
}
if (!entryStats.isDirectory()) {
return [];
}
const directoryEntries = await readdir(entryPath, { withFileTypes: true });
const discoveredFiles = [];
for (const directoryEntry of directoryEntries) {
if (directoryEntry.isDirectory() && skippedDirectories.has(directoryEntry.name)) {
continue;
}
discoveredFiles.push(...await discoverTextFiles(join(entryPath, directoryEntry.name)));
}
return discoveredFiles;
};
const repositoryTextFiles = async () => {
const discoveredFiles = [];
for (const scanRoot of legacyScanRoots) {
const scanPath = resolve(repositoryRoot, scanRoot);
try {
discoveredFiles.push(...await discoverTextFiles(scanPath));
} catch (filesystemError) {
if (filesystemError?.code !== "ENOENT") {
throw filesystemError;
}
}
}
return [...new Set(discoveredFiles)];
};
const legacyNeedles = [
"AltStore",
"PC build",
"Cydia",
"Sileo",
"source.json",
"releases.json",
];
const validateLegacyPurge = async () => {
const errors = [];
const textFiles = await repositoryTextFiles();
for (const textFilePath of textFiles) {
const repositoryRelativePath = relativeRepositoryPath(textFilePath);
const fileText = await readFile(textFilePath, "utf8");
for (const legacyNeedle of legacyNeedles) {
if (fileText.includes(legacyNeedle)) {
errors.push(`${repositoryRelativePath} contains legacy reference: ${legacyNeedle}`);
}
}
}
return errors;
};
const validateOfflineFallback = async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "armsx2-offline-source-"));
try {
const offlineSourcePath = join(temporaryDirectory, "apps.json");
const offlineChecksumsPath = join(temporaryDirectory, "checksums.json");
await execFileAsync(process.execPath, [
generatorPath,
"--offline",
"--output",
offlineSourcePath,
"--checksums",
offlineChecksumsPath,
], { cwd: repositoryRoot });
const offlineSource = await jsonDocument(offlineSourcePath);
const descriptions = (offlineSource.apps ?? [])
.flatMap((sourceApp) => sourceApp.versions ?? [])
.map((sourceVersion) => sourceVersion.localizedDescription)
.filter(Boolean);
if (descriptions.length === 0) {
return ["offline fallback generation produced no version descriptions."];
}
if (descriptions.some((description) => !description.startsWith("Updated to ARMSX2 iOS"))) {
return ["offline fallback generation produced an unpolished changelog."];
}
return [];
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
};
const runValidation = async () => {
const validationOptions = parseArguments(process.argv.slice(2));
const sourceJson = await jsonDocument(resolve(repositoryRoot, validationOptions.sourcePath));
const checksumJson = await jsonDocument(resolve(repositoryRoot, validationOptions.checksumPath));
const errors = [
...await validateAgainstSideStoreSchema(sourceJson),
...validateStrictSourceShape(sourceJson),
...validateChecksumManifest(sourceJson, checksumJson),
...await validateGeneratedMirrors(validationOptions),
...await validateLocalAssets(sourceJson),
...await validateLocalIpas(sourceJson, checksumJson, validationOptions),
...(validationOptions.offlineFallback ? await validateOfflineFallback() : []),
...(validationOptions.legacyPurge ? await validateLegacyPurge() : []),
];
if (errors.length > 0) {
throw new SourceValidationError(errors);
}
console.log("apps.json and checksums.json validate against source, asset, and IPA checks.");
};
try {
await runValidation();
} catch (validationError) {
console.error(validationError instanceof Error ? validationError.message : validationError);
process.exitCode = 1;
}